From a3ccc7a57b9fc79693b554ac9703989ad0e67f68 Mon Sep 17 00:00:00 2001 From: Sylvain Zimmer Date: Thu, 9 Apr 2026 00:53:03 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(channels)=20add=20encryption,=20custo?= =?UTF-8?q?m=20scopes=20and=20levels,=20auditing=20(#599)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Scoped API-key channels with per-scope authorization, personal user channels (users/me/channels), encrypted channel secrets, API-key regeneration (API + admin UI), channel last-used tracking, and a raw email submission endpoint. - Provisioning mailbox lookup/listing API. - Stricter outbound MIME size checks, DKIM signing improvements, and hardened channel/permission validation. - Expanded end-to-end and unit coverage for channels, auth/scopes, provisioning, submission, metrics, and admin flows. --- .github/workflows/messages.yml | 2 +- env.d/development/backend.defaults | 3 - src/backend/core/admin.py | 130 +- src/backend/core/api/authentication.py | 85 ++ src/backend/core/api/openapi.json | 413 +++++- src/backend/core/api/permissions.py | 98 +- src/backend/core/api/serializers.py | 252 +++- src/backend/core/api/viewsets/channel.py | 163 +- src/backend/core/api/viewsets/metrics.py | 27 +- src/backend/core/api/viewsets/provisioning.py | 125 +- src/backend/core/api/viewsets/submit.py | 196 +++ src/backend/core/apps.py | 12 + src/backend/core/enums.py | 115 ++ src/backend/core/factories.py | 58 +- src/backend/core/mda/inbound.py | 2 +- src/backend/core/mda/inbound_create.py | 47 +- src/backend/core/mda/outbound.py | 164 ++- ..._channel_encrypted_settings_scope_level.py | 192 +++ src/backend/core/models.py | 192 ++- src/backend/core/services/search/mapping.py | 11 +- src/backend/core/signals.py | 39 +- .../admin/_object_tools_button_styles.html | 28 + .../admin/core/channel/change_form.html | 24 + .../core/channel/regenerated_api_key.html | 28 + .../admin/core/maildomain/change_form.html | 25 +- .../tests/api/test_channel_api_key_auth.py | 176 +++ .../tests/api/test_channel_scope_level.py | 1312 +++++++++++++++++ src/backend/core/tests/api/test_channels.py | 147 +- .../core/tests/api/test_inbound_widget.py | 14 +- .../tests/api/test_mailbox_usage_metrics.py | 40 +- .../api/test_maildomain_users_metrics.py | 43 +- .../tests/api/test_provisioning_mailbox.py | 422 ++++++ .../api/test_provisioning_maildomains.py | 68 +- src/backend/core/tests/api/test_submit.py | 658 +++++++++ src/backend/core/urls.py | 22 +- src/backend/messages/settings.py | 12 +- .../src/features/api/gen/channels/channels.ts | 1087 +++++++++++++- .../src/features/api/gen/models/channel.ts | 11 +- .../api/gen/models/channel_request.ts | 2 +- .../src/features/api/gen/models/index.ts | 2 + .../src/features/api/gen/models/message.ts | 2 + .../models/regenerated_api_key_response.ts | 14 + .../api/gen/models/scope_level_enum.ts | 24 + 43 files changed, 6226 insertions(+), 261 deletions(-) create mode 100644 src/backend/core/api/authentication.py create mode 100644 src/backend/core/api/viewsets/submit.py create mode 100644 src/backend/core/migrations/0024_channel_encrypted_settings_scope_level.py create mode 100644 src/backend/core/templates/admin/_object_tools_button_styles.html create mode 100644 src/backend/core/templates/admin/core/channel/change_form.html create mode 100644 src/backend/core/templates/admin/core/channel/regenerated_api_key.html create mode 100644 src/backend/core/tests/api/test_channel_api_key_auth.py create mode 100644 src/backend/core/tests/api/test_channel_scope_level.py create mode 100644 src/backend/core/tests/api/test_provisioning_mailbox.py create mode 100644 src/backend/core/tests/api/test_submit.py create mode 100644 src/frontend/src/features/api/gen/models/regenerated_api_key_response.ts create mode 100644 src/frontend/src/features/api/gen/models/scope_level_enum.ts diff --git a/.github/workflows/messages.yml b/.github/workflows/messages.yml index a1854af8..aa680461 100644 --- a/.github/workflows/messages.yml +++ b/.github/workflows/messages.yml @@ -42,7 +42,7 @@ jobs: - name: Create msg-imports bucket run: make import-bucket - name: Run backend tests - run: make test-back + run: make test-back-parallel test-front: runs-on: ubuntu-latest diff --git a/env.d/development/backend.defaults b/env.d/development/backend.defaults index 7abdebfa..5e8f4908 100644 --- a/env.d/development/backend.defaults +++ b/env.d/development/backend.defaults @@ -21,9 +21,6 @@ LOGGING_LEVEL_LOGGERS_APP=INFO ENABLE_PROMETHEUS=0 PROMETHEUS_API_KEY=ExamplePrometheusApiKey -# Metrics -METRICS_API_KEY=ExampleMetricsApiKey - # Python PYTHONPATH=/app diff --git a/src/backend/core/admin.py b/src/backend/core/admin.py index c4d291a0..d9d6cd7b 100644 --- a/src/backend/core/admin.py +++ b/src/backend/core/admin.py @@ -1,12 +1,14 @@ """Admin classes and registrations for core app.""" # pylint: disable=too-many-lines +import json import logging +from django import forms from django.contrib import admin, messages from django.contrib.auth import admin as auth_admin from django.core.files.storage import storages -from django.db.models import Q +from django.db.models import JSONField, Q from django.http import HttpResponseNotAllowed from django.shortcuts import redirect from django.template.response import TemplateResponse @@ -29,6 +31,33 @@ from .enums import MessageDeliveryStatusChoices from .forms import IMAPImportForm, MessageImportForm +class PrettyJSONWidget(forms.Textarea): + """A textarea widget that pretty-prints JSON content.""" + + def __init__(self, attrs=None): + default_attrs = {"cols": 80, "rows": 20, "style": "font-family: monospace;"} + if attrs: + default_attrs.update(attrs) + super().__init__(attrs=default_attrs) + + def format_value(self, value): + if isinstance(value, str): + try: + value = json.dumps(json.loads(value), indent=2, ensure_ascii=False) + except (json.JSONDecodeError, TypeError): + pass + return value + + +# Apply pretty JSON widget globally to every ModelAdmin (in-house and +# third-party). EncryptedJSONField inherits from TextField, not JSONField, +# so encrypted columns are unaffected. +admin.ModelAdmin.formfield_overrides = { + **admin.ModelAdmin.formfield_overrides, + JSONField: {"widget": PrettyJSONWidget}, +} + + class RecipientDeliveryStatusFilter(admin.SimpleListFilter): """Filter messages by their recipients' delivery status.""" @@ -383,27 +412,110 @@ class MailboxAdmin(admin.ModelAdmin): class ChannelAdmin(admin.ModelAdmin): """Admin class for the Channel model""" - list_display = ("name", "type", "mailbox", "maildomain", "created_at") - list_filter = ("type", "created_at") + list_display = ( + "name", + "type", + "scope_level", + "mailbox", + "maildomain", + "user", + "created_at", + ) + list_filter = ("type", "scope_level", "created_at") search_fields = ("name", "type") - readonly_fields = ("created_at", "updated_at") - autocomplete_fields = ("mailbox", "maildomain") + readonly_fields = ("created_at", "updated_at", "last_used_at") + autocomplete_fields = ("mailbox", "maildomain", "user") + change_form_template = "admin/core/channel/change_form.html" fieldsets = ( - (None, {"fields": ("name", "type", "settings")}), + (None, {"fields": ("name", "type", "scope_level", "settings")}), ( "Target", { - "fields": ("mailbox", "maildomain"), - "description": "Specify either a mailbox or maildomain, but not both.", + "fields": ("mailbox", "maildomain", "user"), + "description": ( + "Bind the channel to exactly the target required by its " + "scope_level: 'global' → none; 'maildomain' → maildomain; " + "'mailbox' → mailbox; 'user' → user. On non-user scopes " + "the user FK is an optional creator audit." + ), }, ), ( "Timestamps", - {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}, + { + "fields": ("created_at", "updated_at", "last_used_at"), + "classes": ("collapse",), + }, ), ) + def formfield_for_dbfield(self, db_field, request, **kwargs): + """Constrain ``type`` to known ChannelTypes in the admin form. + + The model field is intentionally a free-form CharField (see + ``ChannelTypes`` docstring — adding a new type must not require a + migration). The choice constraint is therefore admin-only. + """ + if db_field.name == "type": + # pylint: disable=import-outside-toplevel + from core.enums import ChannelTypes + + kwargs["widget"] = forms.Select( + choices=[(t.value, t.value) for t in ChannelTypes] + ) + return db_field.formfield(**kwargs) + return super().formfield_for_dbfield(db_field, request, **kwargs) + + def get_urls(self): + urls = super().get_urls() + custom_urls = [ + path( + "/regenerate-api-key/", + self.admin_site.admin_view(self.regenerate_api_key_view), + name="core_channel_regenerate_api_key", + ), + ] + return custom_urls + urls + + def regenerate_api_key_view(self, request, object_id): + """Regenerate the api_key secret on an api_key channel. + + Delegates the actual rotation to ``Channel.rotate_api_key`` (single + source of truth, shared with the DRF create + regenerate flows). The + plaintext is rendered ONCE in the response body and never stored in + cookies, session, or the messages framework — closing the window + where a credential could leak through signed-cookie message storage. + """ + # pylint: disable=import-outside-toplevel + from core.enums import ChannelTypes + + if request.method != "POST": + return HttpResponseNotAllowed(["POST"]) + + channel = self.get_object(request, object_id) + if channel is None: + messages.error(request, "Channel not found.") + return redirect("..") + if channel.type != ChannelTypes.API_KEY: + messages.error( + request, "Only api_key channels can have their secret regenerated." + ) + return redirect("..") + + plaintext = channel.rotate_api_key() + + context = { + **self.admin_site.each_context(request), + "opts": self.model._meta, # noqa: SLF001 + "original": channel, + "title": "New api_key generated", + "api_key": plaintext, + } + return TemplateResponse( + request, "admin/core/channel/regenerated_api_key.html", context + ) + @admin.register(models.MailboxAccess) class MailboxAccessAdmin(admin.ModelAdmin): diff --git a/src/backend/core/api/authentication.py b/src/backend/core/api/authentication.py new file mode 100644 index 00000000..a4342b9c --- /dev/null +++ b/src/backend/core/api/authentication.py @@ -0,0 +1,85 @@ +"""Authentication classes for service-to-service API calls. + +Today this module ships a single scheme, ChannelApiKeyAuthentication, which +authenticates a request as an api_key Channel via the X-Channel-Id + X-API-Key +headers. New schemes (mTLS, signed JWT, OIDC client credentials, …) should be +added here as additional BaseAuthentication subclasses that set +``request.auth`` to a Channel instance the same way. The downstream permission +layer (``HasChannelScope``) is scheme-agnostic — it only inspects +``request.auth``. +""" + +import hashlib +from secrets import compare_digest + +from django.contrib.auth.models import AnonymousUser +from django.core.exceptions import ValidationError as DjangoValidationError +from django.utils import timezone +from django.utils.dateparse import parse_datetime + +from rest_framework.authentication import BaseAuthentication +from rest_framework.exceptions import AuthenticationFailed + +from core import models +from core.enums import ChannelTypes + + +class ChannelApiKeyAuthentication(BaseAuthentication): + """Authenticate as an api_key Channel via X-Channel-Id + X-API-Key. + + Client contract: + X-Channel-Id: (public, identifies which channel) + X-API-Key: (the shared secret, hashed at rest) + + On success ``request.user`` is set to ``AnonymousUser`` (there is no + associated user) and ``request.auth`` is set to the authenticated + ``Channel`` instance. Views must read ``request.auth.scope_level``, + ``request.auth.mailbox_id`` and ``request.auth.maildomain_id`` to + enforce resource-level bounds on the action they perform. + """ + + def authenticate(self, request): + channel_id = request.headers.get("X-Channel-Id") + api_key = request.headers.get("X-API-Key") + + # Missing either header → this auth scheme does not apply; let DRF + # try the next class in authentication_classes. Returning None here + # is the documented way to skip. + if not channel_id or not api_key: + return None + + try: + channel = models.Channel.objects.select_related( + "mailbox", "maildomain", "user" + ).get(pk=channel_id, type=ChannelTypes.API_KEY) + except (models.Channel.DoesNotExist, ValueError, DjangoValidationError) as exc: + # ValueError / ValidationError handle malformed UUIDs. + raise AuthenticationFailed("Invalid channel or API key.") from exc + + provided_hash = hashlib.sha256(api_key.encode("utf-8")).hexdigest() + stored_hashes = (channel.encrypted_settings or {}).get("api_key_hashes") or [] + # Iterate every stored hash without early exit so the timing is + # constant with respect to *which* slot matched (the total number + # of slots is not secret — there is no hard cap on the array). Any + # match flips the boolean. + matched = False + for stored in stored_hashes: + if isinstance(stored, str) and compare_digest(stored, provided_hash): + matched = True + if not matched: + raise AuthenticationFailed("Invalid channel or API key.") + + expires_at_raw = (channel.settings or {}).get("expires_at") + if expires_at_raw: + expires_at = parse_datetime(expires_at_raw) + if expires_at is not None and expires_at < timezone.now(): + raise AuthenticationFailed("API key has expired.") + + # Throttled update of last_used_at for monitoring (5 min window). + channel.mark_used() + + return (AnonymousUser(), channel) + + def authenticate_header(self, request): + # DRF uses this as the WWW-Authenticate header on 401 responses. + return "X-API-Key" diff --git a/src/backend/core/api/openapi.json b/src/backend/core/api/openapi.json index 1f2df39b..30d81591 100644 --- a/src/backend/core/api/openapi.json +++ b/src/backend/core/api/openapi.json @@ -2602,13 +2602,7 @@ } } }, - "description": "Channel created successfully" - }, - "400": { - "description": "Invalid input data" - }, - "403": { - "description": "Permission denied" + "description": "" } } } @@ -2824,6 +2818,60 @@ } } }, + "/api/v1.0/mailboxes/{mailbox_id}/channels/{id}/regenerate-api-key/": { + "post": { + "operationId": "mailboxes_channels_regenerate_api_key_create", + "description": "Manage integration channels for a mailbox", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "mailbox_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "channels" + ], + "security": [ + { + "cookieAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegeneratedApiKeyResponse" + } + } + }, + "description": "Returns the freshly generated plaintext api_key. The previous secret is invalidated immediately. The plaintext is shown ONCE and cannot be retrieved later." + }, + "400": { + "description": "Channel is not an api_key channel" + }, + "403": { + "description": "Permission denied" + }, + "404": { + "description": "Channel not found" + } + } + } + }, "/api/v1.0/mailboxes/{mailbox_id}/image-proxy/": { "get": { "operationId": "mailboxes_image_proxy_list", @@ -6007,6 +6055,294 @@ } } } + }, + "/api/v1.0/users/me/channels/": { + "get": { + "operationId": "users_me_channels_list", + "description": "Manage personal (scope_level=user) integration channels", + "tags": [ + "channels" + ], + "security": [ + { + "cookieAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Channel" + } + } + } + }, + "description": "" + } + } + }, + "post": { + "operationId": "users_me_channels_create", + "description": "Manage personal (scope_level=user) integration channels", + "tags": [ + "channels" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChannelRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ChannelRequest" + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Channel" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1.0/users/me/channels/{id}/": { + "get": { + "operationId": "users_me_channels_retrieve", + "description": "Manage personal (scope_level=user) integration channels", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "channels" + ], + "security": [ + { + "cookieAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Channel" + } + } + }, + "description": "" + } + } + }, + "put": { + "operationId": "users_me_channels_update", + "description": "Manage personal (scope_level=user) integration channels", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "channels" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChannelRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ChannelRequest" + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Channel" + } + } + }, + "description": "Channel updated successfully" + }, + "400": { + "description": "Invalid input data" + }, + "403": { + "description": "Permission denied" + }, + "404": { + "description": "Channel not found" + } + } + }, + "patch": { + "operationId": "users_me_channels_partial_update", + "description": "Manage personal (scope_level=user) integration channels", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "channels" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedChannelRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedChannelRequest" + } + } + } + }, + "security": [ + { + "cookieAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Channel" + } + } + }, + "description": "" + } + } + }, + "delete": { + "operationId": "users_me_channels_destroy", + "description": "Manage personal (scope_level=user) integration channels", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "channels" + ], + "security": [ + { + "cookieAuth": [] + } + ], + "responses": { + "204": { + "description": "Channel deleted successfully" + }, + "403": { + "description": "Permission denied" + }, + "404": { + "description": "Channel not found" + } + } + } + }, + "/api/v1.0/users/me/channels/{id}/regenerate-api-key/": { + "post": { + "operationId": "users_me_channels_regenerate_api_key_create", + "description": "Manage personal (scope_level=user) integration channels", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "channels" + ], + "security": [ + { + "cookieAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegeneratedApiKeyResponse" + } + } + }, + "description": "Returns the freshly generated plaintext api_key. The previous secret is invalidated immediately. The plaintext is shown ONCE and cannot be retrieved later." + }, + "400": { + "description": "Channel is not an api_key channel" + }, + "403": { + "description": "Permission denied" + }, + "404": { + "description": "Channel not found" + } + } + } } }, "components": { @@ -6131,6 +6467,14 @@ "description": "Type of channel", "maxLength": 255 }, + "scope_level": { + "allOf": [ + { + "$ref": "#/components/schemas/ScopeLevelEnum" + } + ], + "readOnly": true + }, "settings": { "description": "Channel-specific configuration settings" }, @@ -6148,6 +6492,19 @@ "readOnly": true, "nullable": true }, + "user": { + "type": "string", + "format": "uuid", + "description": "primary key for the record as UUID", + "readOnly": true, + "nullable": true + }, + "last_used_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, "created_at": { "type": "string", "format": "date-time", @@ -6166,10 +6523,14 @@ "required": [ "created_at", "id", + "last_used_at", "mailbox", "maildomain", "name", - "updated_at" + "scope_level", + "type", + "updated_at", + "user" ] }, "ChannelRequest": { @@ -6193,7 +6554,8 @@ } }, "required": [ - "name" + "name", + "type" ] }, "Contact": { @@ -7490,6 +7852,11 @@ "type": "boolean", "readOnly": true }, + "mime_id": { + "type": "string", + "readOnly": true, + "nullable": true + }, "signature": { "allOf": [ { @@ -7520,6 +7887,7 @@ "is_sender", "is_trashed", "is_unread", + "mime_id", "parent_id", "sender", "sender_user", @@ -8281,6 +8649,23 @@ "updated_at" ] }, + "RegeneratedApiKeyResponse": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Channel id (also the X-Channel-Id header value)." + }, + "api_key": { + "type": "string", + "description": "Freshly generated plaintext api_key. Returned ONCE on regeneration and cannot be retrieved later." + } + }, + "required": [ + "api_key", + "id" + ] + }, "ResetPasswordError": { "type": "object", "properties": { @@ -8325,6 +8710,16 @@ "one_time_password" ] }, + "ScopeLevelEnum": { + "enum": [ + "global", + "maildomain", + "mailbox", + "user" + ], + "type": "string", + "description": "* `global` - Global\n* `maildomain` - Maildomain\n* `mailbox` - Mailbox\n* `user` - User" + }, "SendMessageRequest": { "type": "object", "description": "Serializer for sending messages.", diff --git a/src/backend/core/api/permissions.py b/src/backend/core/api/permissions.py index 814a3bff..c9d3b146 100644 --- a/src/backend/core/api/permissions.py +++ b/src/backend/core/api/permissions.py @@ -1,11 +1,9 @@ """Permission handlers for the messages core app.""" -from secrets import compare_digest - -from django.conf import settings from django.core import exceptions from rest_framework import permissions +from rest_framework.exceptions import PermissionDenied from core import enums, models @@ -458,26 +456,86 @@ class IsMailboxAdmin(permissions.BasePermission): return is_domain_admin -class HasMetricsApiKey(permissions.BasePermission): - """Allows access only to users with the metrics API key.""" +class HasChannelScope(permissions.BasePermission): + """Scope-based permission for service calls authenticated as a Channel. + + Auth-scheme-agnostic: it only inspects ``request.auth`` (which must be a + ``Channel`` set by an authentication class like ``ChannelApiKeyAuthentication``) + and checks that ``required_scope`` is listed in the channel's + ``settings["scopes"]``. Additionally, if the required scope is in + ``CHANNEL_API_KEY_SCOPES_GLOBAL_ONLY``, the calling channel must itself + be ``scope_level=global`` — this is the second of two enforcement points + for global-only scopes (the first is the serializer at write time). + + The view remains responsible for resource-level bounds via + ``request.auth.api_key_covers(...)`` — this class does not know which + mailbox or domain the action is about to touch. + + Subclasses set ``required_scope`` — use the ``channel_scope()`` factory + below to create one on the fly so ``permission_classes`` can reference it + declaratively. + """ + + required_scope: str = "" def has_permission(self, request, view): - return compare_digest( - request.headers.get("Authorization") or "", - f"Bearer {settings.METRICS_API_KEY}", - ) - - -class HasProvisioningApiKey(permissions.BasePermission): - """Allows access only to requests bearing the provisioning API key.""" - - def has_permission(self, request, view): - if not settings.PROVISIONING_API_KEY: + channel = request.auth + if not isinstance(channel, models.Channel): return False - return compare_digest( - request.headers.get("Authorization") or "", - f"Bearer {settings.PROVISIONING_API_KEY}", - ) + if not isinstance(channel.settings, dict): + return False + scopes = channel.settings.get("scopes") + if not isinstance(scopes, list) or not all(isinstance(s, str) for s in scopes): + return False + if self.required_scope not in scopes: + return False + if ( + self.required_scope in enums.CHANNEL_API_KEY_SCOPES_GLOBAL_ONLY + and channel.scope_level != enums.ChannelScopeLevel.GLOBAL + ): + return False + return True + + +def channel_scope(required_scope: str) -> type: + """Return a ``HasChannelScope`` subclass with ``required_scope`` pre-bound. + + DRF's ``permission_classes`` expects a list of classes, not instances, so + we synthesize a tiny subclass per scope. Usage: + + permission_classes = [channel_scope(ChannelApiKeyScope.MESSAGES_SEND)] + """ + return type( + f"HasChannelScope_{required_scope}", + (HasChannelScope,), + {"required_scope": str(required_scope)}, + ) + + +class IsGlobalChannelMixin: + """Mixin for APIViews that require ``request.auth`` to be a Channel + with ``scope_level=global``. Drop this onto a view *in addition to* + ``ChannelApiKeyAuthentication`` + ``channel_scope(...)``. + + Two-layer defense in depth: even when the required scope is in + ``CHANNEL_API_KEY_SCOPES_GLOBAL_ONLY`` (and ``HasChannelScope`` already + rejects non-global callers), the view itself re-asserts the requirement + so a future scope-set typo can't accidentally weaken the endpoint. The + mixin's ``initial()`` runs after authentication+permission, so by the + time it executes ``request.auth`` is guaranteed to be a Channel. + """ + + def initial(self, request, *args, **kwargs): + """Re-assert scope_level=global after authentication+permission run.""" + super().initial(request, *args, **kwargs) + channel = getattr(request, "auth", None) + if ( + not isinstance(channel, models.Channel) + or channel.scope_level != enums.ChannelScopeLevel.GLOBAL + ): + # Generic message — do not leak the scope_level requirement + # to the caller. + raise PermissionDenied() class HasThreadEditAccess(IsAuthenticated): diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index f69415cd..97db88bb 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -944,6 +944,7 @@ class MessageSerializer(serializers.ModelSerializer): "is_trashed", "is_archived", "has_attachments", + "mime_id", "signature", "stmsg_headers", ] @@ -1247,9 +1248,7 @@ class MailboxAdminSerializer(serializers.ModelSerializer): if metadata.get("type") == "personal": local_part = attrs.get("local_part", "") - denylist = getattr( - settings, "MESSAGES_MAILBOX_LOCALPART_DENYLIST_PERSONAL", [] - ) + denylist = settings.MESSAGES_MAILBOX_LOCALPART_DENYLIST_PERSONAL lower_value = local_part.lower() if any(lower_value == prefix.lower() for prefix in denylist): raise serializers.ValidationError( @@ -1542,12 +1541,17 @@ class ImportIMAPSerializer(ImportBaseSerializer): ) -class ChannelSerializer(serializers.ModelSerializer): +class ChannelSerializer(CreateOnlyFieldsMixin, serializers.ModelSerializer): """Serialize Channel model.""" # Explicitly mark nullable fields to fix OpenAPI schema mailbox = serializers.PrimaryKeyRelatedField(read_only=True, allow_null=True) maildomain = serializers.PrimaryKeyRelatedField(read_only=True, allow_null=True) + user = serializers.PrimaryKeyRelatedField(read_only=True, allow_null=True) + scope_level = serializers.ChoiceField( + choices=enums.ChannelScopeLevel.choices, read_only=True + ) + last_used_at = serializers.DateTimeField(read_only=True, allow_null=True) class Meta: model = models.Channel @@ -1555,13 +1559,60 @@ class ChannelSerializer(serializers.ModelSerializer): "id", "name", "type", + "scope_level", "settings", "mailbox", "maildomain", + "user", + "last_used_at", "created_at", "updated_at", ] - read_only_fields = ["id", "mailbox", "maildomain", "created_at", "updated_at"] + read_only_fields = [ + "id", + "mailbox", + "maildomain", + "user", + "scope_level", + "last_used_at", + "created_at", + "updated_at", + ] + # ``type`` is writable on CREATE only — once a channel exists its + # type is frozen. PATCHing type=widget on an api_key channel + # would let a mailbox admin sneak around the create-time scope + # check (the new type wouldn't trip _validate_api_key_scopes) or + # strand encrypted_settings on a row whose type the auth class + # no longer recognizes. Lock it down via CreateOnlyFieldsMixin. + create_only_fields = ["type"] + + # Per-type list of settings keys that are server-managed and must + # NEVER appear in caller-supplied ``settings``. Used by + # ``_reject_caller_supplied_encrypted_keys`` below — there is no + # automatic move-from-settings-to-encrypted shim, server-side + # generators write directly to ``encrypted_settings`` instead. + RESERVED_SETTINGS_KEYS = { + enums.ChannelTypes.API_KEY: ["api_key_hashes"], + } + + def create(self, validated_data): + # For api_key channels, mint the secret on a transient instance so + # the resulting ``encrypted_settings`` rides through the normal + # ``super().create()`` save path. The plaintext is stashed on the + # saved row for ChannelViewSet.create() to surface exactly once, + # mirroring the ``_generated_password`` pattern at channel.py:68-74. + generated_api_key = None + if validated_data.get("type") == enums.ChannelTypes.API_KEY: + transient = models.Channel(**validated_data) + generated_api_key = transient.rotate_api_key(save=False) + validated_data["encrypted_settings"] = transient.encrypted_settings + + instance = super().create(validated_data) + + # pylint: disable=protected-access + if generated_api_key is not None: + instance._generated_api_key = generated_api_key # noqa: SLF001 + return instance def validate_settings(self, value): """Validate settings, including tags if present.""" @@ -1610,19 +1661,190 @@ class ChannelSerializer(serializers.ModelSerializer): return value + def _reject_caller_supplied_encrypted_keys(self, attrs): + """Refuse any request that puts ``RESERVED_SETTINGS_KEYS`` values + in ``settings``. + + ``encrypted_settings`` itself is not in ``ChannelSerializer.fields``, + so callers can't write it directly. Reserved settings keys (e.g. + ``api_key_hashes``) are written by server-side generators and + must never originate from a request body. Without this check, a + mailbox admin could PATCH ``{"settings": {"scopes": [...], + "api_key_hashes": []}}`` and the row's settings JSON + would carry the attacker-chosen value alongside the legitimate + ones — confusing, even when not directly authenticatable. + """ + settings_data = attrs.get("settings") + if not isinstance(settings_data, dict): + return + # Use the resolved type so this works on both CREATE and PATCH. + channel_type = attrs.get("type") or ( + self.instance.type if self.instance else None + ) + reserved = self.RESERVED_SETTINGS_KEYS.get(channel_type, []) + if any(k in settings_data for k in reserved): + raise serializers.ValidationError({"settings": "Invalid settings."}) + + def _resolve_type_and_settings(self, attrs): + """Compute (channel_type, settings_data, should_validate) for a + type-specific validator. The pair handles all three lifecycle paths: + + - CREATE: ``type`` and ``settings`` come from ``attrs``. + - PATCH/PUT touching settings (or type): ``settings`` overrides + the instance's existing JSON; ``type`` falls back to the + instance. + - PATCH that doesn't touch ``type`` or ``settings`` (e.g. a + rename): the validator is skipped because the field being + validated isn't in play. ``should_validate`` is False. + + This is the central fix for the PATCH-bypass escalation: the old + code returned early when ``attrs.get("type")`` was None, which is + the typical PATCH shape, allowing a mailbox admin to PATCH + ``{"settings": {"scopes": ["maildomains:create"]}}`` and have it + slip through unvalidated. + """ + instance = self.instance + channel_type = attrs.get("type") or (instance.type if instance else None) + settings_in_attrs = "settings" in attrs + + # If neither type nor settings are being introduced/changed, this + # validator has nothing to look at. + if not settings_in_attrs and "type" not in attrs: + return channel_type, None, False + + if settings_in_attrs: + settings_data = attrs.get("settings") or {} + elif instance is not None: + # type was changed but settings weren't — fall back to the + # instance's existing settings so we validate the post-save + # combination. + settings_data = instance.settings or {} + else: + settings_data = {} + + return channel_type, settings_data, True + + def _validate_api_key_scopes(self, attrs): + """Validate the ``settings["scopes"]`` list on api_key channels. + + Runs on every CREATE and on every PATCH/PUT that touches ``type`` + or ``settings``. The validator looks at the EFFECTIVE post-save + state — explicit attrs win, instance state is the fallback — + which is the only way to airtight-block a mailbox admin from + granting themselves a global-only scope via a settings-only PATCH. + + - Every value must be a member of ``ChannelApiKeyScope``. + - Global-only scopes (e.g. ``maildomains:create``) can only be + requested when the channel itself has ``scope_level=global``. + Since DRF clients cannot set scope_level, this always rejects + global-only scopes on the mailbox-nested and user-nested paths. + """ + channel_type, settings_data, should_validate = self._resolve_type_and_settings( + attrs + ) + if not should_validate or channel_type != enums.ChannelTypes.API_KEY: + return + + raw_scopes = settings_data.get("scopes") + if raw_scopes is None: + raise serializers.ValidationError( + { + "settings": "api_key channels require settings.scopes (a list of strings)." + } + ) + if not isinstance(raw_scopes, list) or not all( + isinstance(s, str) for s in raw_scopes + ): + raise serializers.ValidationError( + {"settings": "settings.scopes must be a list of strings."} + ) + + valid_values = {choice.value for choice in enums.ChannelApiKeyScope} + unknown = [s for s in raw_scopes if s not in valid_values] + if unknown: + raise serializers.ValidationError( + {"settings": f"Unknown api_key scopes: {unknown}"} + ) + + # Any DRF path to this serializer is mailbox-nested or user-nested + # today, so the effective scope_level on save will be MAILBOX or + # USER. Global-only scopes are therefore never grantable via DRF. + # If that ever changes, the viewset must still pass scope_level + # explicitly on save. + global_only = enums.CHANNEL_API_KEY_SCOPES_GLOBAL_ONLY + if any(s in global_only for s in raw_scopes): + raise serializers.ValidationError( + {"settings": "One or more requested scopes are not permitted."} + ) + + def _validate_webhook_settings(self, attrs): + """Validate required fields on webhook channel settings. + + Runs on CREATE and on every PATCH/PUT touching ``type`` or + ``settings``. Same airtight rule as ``_validate_api_key_scopes``: + a settings-only PATCH on an existing webhook channel must hit the + URL/events validators, otherwise a mailbox admin could PATCH + ``{"settings": {"url": "javascript:..."}}`` past the create-time + check. + """ + channel_type, settings_data, should_validate = self._resolve_type_and_settings( + attrs + ) + if not should_validate or channel_type != enums.ChannelTypes.WEBHOOK: + return + + url = settings_data.get("url") + if not url or not isinstance(url, str): + raise serializers.ValidationError( + {"settings": "webhook channels require settings.url (a string)."} + ) + if not url.startswith(("http://", "https://")): + raise serializers.ValidationError( + {"settings": "webhook settings.url must be http:// or https://"} + ) + + events = settings_data.get("events") + if not events or not isinstance(events, list): + raise serializers.ValidationError( + { + "settings": "webhook channels require settings.events (a non-empty list)." + } + ) + unknown = [e for e in events if e not in enums.WebhookEvents] + if unknown: + raise serializers.ValidationError( + {"settings": f"Unknown webhook events: {unknown}"} + ) + def validate(self, attrs): """Validate channel data. - When used in the nested mailbox context (via ChannelViewSet), - the mailbox is set from context and doesn't need to be validated here. + When used in the nested mailbox context (via ChannelViewSet) or the + user context (via UserChannelViewSet), the target FK is set by the + viewset and doesn't need to be validated here. Otherwise this is + the Django-admin / management-command path which still requires an + explicit mailbox or maildomain. """ - # If we have a mailbox in context (from ChannelViewSet), validate channel type - # and skip mailbox/maildomain validation. - # This allows Django admin to create any channel type. - if self.context.get("mailbox"): + if self.context.get("mailbox") or self.context.get("user_channel"): + # On CREATE, ``type`` MUST be supplied explicitly. The model + # field used to default to "mta", which let a caller omit + # ``type`` from the body and bypass FEATURE_MAILBOX_ADMIN_CHANNELS + # even when "mta" was not in the allowlist. On UPDATE, ``type`` + # is made read-only by ``CreateOnlyFieldsMixin`` so it's never + # in ``attrs`` — fall through to the other validators which + # read the instance's existing type. + if self.instance is None and "type" not in attrs: + raise serializers.ValidationError( + { + "type": ( + "Channel type is required for mailbox/user " + "channel creation." + ) + } + ) channel_type = attrs.get("type") if channel_type: - allowed_types = settings.FEATURE_MAILBOX_ADMIN_CHANNELS + allowed_types = list(settings.FEATURE_MAILBOX_ADMIN_CHANNELS) if channel_type not in allowed_types: raise serializers.ValidationError( { @@ -1630,6 +1852,9 @@ class ChannelSerializer(serializers.ModelSerializer): f"Allowed types: {', '.join(allowed_types)}" } ) + self._reject_caller_supplied_encrypted_keys(attrs) + self._validate_api_key_scopes(attrs) + self._validate_webhook_settings(attrs) return attrs mailbox = attrs.get("mailbox") @@ -1646,6 +1871,9 @@ class ChannelSerializer(serializers.ModelSerializer): "Cannot specify both mailbox and maildomain." ) + self._reject_caller_supplied_encrypted_keys(attrs) + self._validate_api_key_scopes(attrs) + self._validate_webhook_settings(attrs) return attrs diff --git a/src/backend/core/api/viewsets/channel.py b/src/backend/core/api/viewsets/channel.py index c3c764df..20093209 100644 --- a/src/backend/core/api/viewsets/channel.py +++ b/src/backend/core/api/viewsets/channel.py @@ -6,11 +6,16 @@ from django.utils.functional import cached_property from drf_spectacular.utils import ( OpenApiResponse, extend_schema, + inline_serializer, ) from rest_framework import mixins, status, viewsets +from rest_framework import serializers as drf_serializers +from rest_framework.decorators import action +from rest_framework.exceptions import ValidationError from rest_framework.response import Response from core import models +from core.enums import ChannelScopeLevel, ChannelTypes from .. import permissions, serializers @@ -39,10 +44,17 @@ class ChannelViewSet( return get_object_or_404(models.Mailbox, id=self.kwargs["mailbox_id"]) def get_queryset(self): - """Get channels for the mailbox the user has admin access to.""" - return models.Channel.objects.filter(mailbox=self.mailbox).order_by( - "-created_at" - ) + """Get channels for the mailbox the user has admin access to. + + Defense-in-depth: filter explicitly on scope_level=MAILBOX even though + the mailbox FK filter already excludes global/maildomain rows. Any + accidentally force-inserted row with a non-null mailbox_id and a + non-mailbox scope_level would be excluded here. + """ + return models.Channel.objects.filter( + mailbox=self.mailbox, + scope_level=ChannelScopeLevel.MAILBOX, + ).order_by("-created_at") def get_serializer_context(self): """Add mailbox to serializer context.""" @@ -61,12 +73,50 @@ class ChannelViewSet( 403: OpenApiResponse(description="Permission denied"), }, ) + def get_save_kwargs(self): + """Hook for subclasses to inject the scope-level + target FKs. + + This base class is mailbox-nested, so it always saves with + scope_level=MAILBOX bound to the URL mailbox. ``user`` is stamped + as the creator audit (the user FK doubles as the target on + scope_level=user channels but is the audit creator everywhere + else). Subclasses (e.g. UserChannelViewSet) override this to bind + to a different scope. + """ + return { + "mailbox": self.mailbox, + "scope_level": ChannelScopeLevel.MAILBOX, + "user": self.request.user, + } + def create(self, request, *args, **kwargs): - """Create a new channel for the mailbox.""" + """Create a new channel. + + Always forces scope_level on save through ``get_save_kwargs``: + non-superadmins cannot create global channels through DRF even if a + validation bug slipped ``scope_level`` past the serializer. + + The response includes the row's ``id`` (which is also the value of + the ``X-Channel-Id`` header on subsequent api_key calls), and on + creation only, the freshly generated plaintext secrets — these + cannot be retrieved later. + """ serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) - serializer.save(mailbox=self.mailbox) - return Response(serializer.data, status=status.HTTP_201_CREATED) + instance = serializer.save(**self.get_save_kwargs()) + data = serializer.data + + # Surface plaintext secrets exactly once on creation. Each generator + # lives on the instance under `_generated_*` (see ChannelSerializer). + # Subsequent GETs never return any of these. + for attr, response_key in ( + ("_generated_password", "password"), + ("_generated_api_key", "api_key"), + ): + value = getattr(instance, attr, None) + if value: + data[response_key] = value + return Response(data, status=status.HTTP_201_CREATED) @extend_schema( request=serializers.ChannelSerializer, @@ -101,3 +151,102 @@ class ChannelViewSet( instance = self.get_object() self.perform_destroy(instance) return Response(status=status.HTTP_204_NO_CONTENT) + + @extend_schema( + request=None, + responses={ + 200: OpenApiResponse( + response=inline_serializer( + name="RegeneratedApiKeyResponse", + fields={ + "id": drf_serializers.CharField( + help_text="Channel id (also the X-Channel-Id header value).", + ), + "api_key": drf_serializers.CharField( + help_text=( + "Freshly generated plaintext api_key. Returned " + "ONCE on regeneration and cannot be retrieved later." + ), + ), + }, + ), + description=( + "Returns the freshly generated plaintext api_key. The " + "previous secret is invalidated immediately. The plaintext " + "is shown ONCE and cannot be retrieved later." + ), + ), + 400: OpenApiResponse(description="Channel is not an api_key channel"), + 403: OpenApiResponse(description="Permission denied"), + 404: OpenApiResponse(description="Channel not found"), + }, + ) + @action(detail=True, methods=["post"], url_path="regenerate-api-key") + def regenerate_api_key(self, request, *args, **kwargs): + """Regenerate the api_key on this channel. + + Single-active rotation: the new secret REPLACES the old one + immediately, so any client still using the old secret will + start failing on the next call. This is the only rotation + flow exposed via DRF. + + Smooth (dual-active) rotation — appending a new hash without + removing the old one so clients can migrate over a window — is + intentionally a superadmin-only feature available via Django admin. + """ + instance = self.get_object() + if instance.type != ChannelTypes.API_KEY: + raise ValidationError( + {"type": "Only api_key channels can have their secret regenerated."} + ) + + plaintext = instance.rotate_api_key() + + return Response( + {"id": str(instance.id), "api_key": plaintext}, + status=status.HTTP_200_OK, + ) + + +@extend_schema( + tags=["channels"], + description="Manage personal (scope_level=user) integration channels", +) +# pylint: disable=too-many-ancestors +class UserChannelViewSet(ChannelViewSet): + """Personal channels owned by the requesting user. + + Mounted at ``/api/v1.0/users/me/channels/``. Reuses the base class's + create/update/destroy plumbing — the only differences are the queryset + filter, the permission class, and the save kwargs that bind the row + to ``scope_level=user``. + """ + + permission_classes = [permissions.IsAuthenticated] + + @cached_property + def mailbox(self): + """No mailbox in this nesting — explicitly disable the parent's + cached property to make accidental access loud.""" + return None + + def get_queryset(self): + return models.Channel.objects.filter( + user=self.request.user, + scope_level=ChannelScopeLevel.USER, + ).order_by("-created_at") + + def get_serializer_context(self): + # Skip the mailbox-context branch in ChannelSerializer.validate so + # the serializer's validation falls back to the user_channel path. + # The user-scope serializer.save() below still hardcodes scope_level + # + user, so a body-supplied user= would be ignored regardless. + context = super(ChannelViewSet, self).get_serializer_context() + context["user_channel"] = True + return context + + def get_save_kwargs(self): + return { + "user": self.request.user, + "scope_level": ChannelScopeLevel.USER, + } diff --git a/src/backend/core/api/viewsets/metrics.py b/src/backend/core/api/viewsets/metrics.py index 13d0e8ee..3ac2ad6a 100644 --- a/src/backend/core/api/viewsets/metrics.py +++ b/src/backend/core/api/viewsets/metrics.py @@ -13,7 +13,9 @@ from drf_spectacular.utils import extend_schema from rest_framework.response import Response from rest_framework.views import APIView -from core.api.permissions import HasMetricsApiKey +from core.api.authentication import ChannelApiKeyAuthentication +from core.api.permissions import IsGlobalChannelMixin, channel_scope +from core.enums import ChannelApiKeyScope from core.models import ( Attachment, Blob, @@ -33,13 +35,18 @@ ACTIVE_USER_METRICS = { } -class MailDomainUsersMetricsApiView(APIView): +class MailDomainUsersMetricsApiView(IsGlobalChannelMixin, APIView): """ - API view to expose MailDomain Users custom metrics + API view to expose MailDomain Users custom metrics. Global-only. + + ``METRICS_READ`` is in CHANNEL_API_KEY_SCOPES_GLOBAL_ONLY (so the + permission layer rejects non-global api_key channels), and + ``IsGlobalChannelMixin`` re-asserts the same invariant in the view as + a second defense-in-depth layer. """ - permission_classes = [HasMetricsApiKey] - authentication_classes = [] # Disable any authentication + authentication_classes = [ChannelApiKeyAuthentication] + permission_classes = [channel_scope(ChannelApiKeyScope.METRICS_READ)] @extend_schema(exclude=True) def get(self, request): @@ -54,7 +61,6 @@ class MailDomainUsersMetricsApiView(APIView): metrics = defaultdict(lambda: {"metrics": {}}) for metric, threshold in ACTIVE_USER_METRICS.items(): - # Build the base queryset queryset = MailboxAccess.objects.select_related( "mailbox", "mailbox__domain" ) @@ -195,13 +201,14 @@ class MailDomainUsersMetricsApiView(APIView): return Response({"count": len(metrics), "results": list(metrics.values())}) -class MailboxUsageMetricsApiView(APIView): +class MailboxUsageMetricsApiView(IsGlobalChannelMixin, APIView): """ - API view to expose per-mailbox storage usage metrics. + API view to expose per-mailbox storage usage metrics. Global-only — + see ``MailDomainUsersMetricsApiView`` for the rationale. """ - permission_classes = [HasMetricsApiKey] - authentication_classes = [] # Disable any authentication + authentication_classes = [ChannelApiKeyAuthentication] + permission_classes = [channel_scope(ChannelApiKeyScope.METRICS_READ)] @extend_schema(exclude=True) def get(self, request): diff --git a/src/backend/core/api/viewsets/provisioning.py b/src/backend/core/api/viewsets/provisioning.py index 0ab7ee45..78eac30a 100644 --- a/src/backend/core/api/viewsets/provisioning.py +++ b/src/backend/core/api/viewsets/provisioning.py @@ -1,4 +1,4 @@ -"""API view for provisioning mail domains from DeployCenter.""" +"""Provisioning API views (service-to-service, API key auth).""" import logging @@ -11,18 +11,23 @@ from rest_framework.response import Response from rest_framework.views import APIView from sentry_sdk import capture_exception -from core.api.permissions import HasProvisioningApiKey -from core.api.serializers import ProvisioningMailDomainSerializer -from core.models import MailDomain +from core import models +from core.api.authentication import ChannelApiKeyAuthentication +from core.api.permissions import IsGlobalChannelMixin, channel_scope +from core.api.serializers import ( + MailboxLightSerializer, + ProvisioningMailDomainSerializer, +) +from core.enums import ChannelApiKeyScope, MailboxRoleChoices logger = logging.getLogger(__name__) -class ProvisioningMailDomainView(APIView): - """Provision mail domains from DeployCenter webhooks.""" +class ProvisioningMailDomainView(IsGlobalChannelMixin, APIView): + """Provision mail domains from DeployCenter webhooks. Global-only.""" - permission_classes = [HasProvisioningApiKey] - authentication_classes = [] + authentication_classes = [ChannelApiKeyAuthentication] + permission_classes = [channel_scope(ChannelApiKeyScope.MAILDOMAINS_CREATE)] @extend_schema(exclude=True) def post(self, request): @@ -41,7 +46,7 @@ class ProvisioningMailDomainView(APIView): for domain_name in domains: try: - domain, was_created = MailDomain.objects.get_or_create( + domain, was_created = models.MailDomain.objects.get_or_create( name=domain_name, defaults={ "custom_attributes": custom_attributes, @@ -87,3 +92,105 @@ class ProvisioningMailDomainView(APIView): }, status=status.HTTP_200_OK, ) + + +def _serialize_mailbox_with_users( + mailbox, role=None, maildomain_custom_attributes=None +): + """Serialize a mailbox with all its users and their roles.""" + data = MailboxLightSerializer(mailbox).data + if role is not None: + data["role"] = role + data["users"] = [ + { + "email": access.user.email, + "role": MailboxRoleChoices(access.role).label, + } + for access in mailbox.accesses.select_related("user").all() + ] + if maildomain_custom_attributes: + domain_attrs = mailbox.domain.custom_attributes or {} + data["maildomain_custom_attributes"] = { + key: domain_attrs.get(key) for key in maildomain_custom_attributes + } + return data + + +class ProvisioningMailboxView(IsGlobalChannelMixin, APIView): + """List mailboxes for a user or look up a mailbox by email. + + Each mailbox includes a ``users`` array with all users who have + access and their roles, so callers can sync shares in one request. + + GET /api/v1.0/provisioning/mailboxes/?user_email=... + GET /api/v1.0/provisioning/mailboxes/?email=... + + **Global-scope api_key channels only.** This endpoint is intentionally + not exposed to maildomain- or mailbox-scope keys, even though such a key + could only see a subset of the data — the threat model is that any + leak of a credential able to enumerate mailboxes is treated as a + privileged event, and only ops/CI keys should be able to do it. The + ``IsGlobalChannelMixin`` enforces that. + """ + + authentication_classes = [ChannelApiKeyAuthentication] + permission_classes = [channel_scope(ChannelApiKeyScope.MAILBOXES_READ)] + + @extend_schema(exclude=True) + def get(self, request): + """Return mailboxes filtered by user_email or email query parameter.""" + user_email = request.query_params.get("user_email") + email = request.query_params.get("email") + + # Optional: include specific keys from MailDomain.custom_attributes + # e.g. ?add_maildomain_custom_attributes=siret,org_name + raw = request.query_params.get("add_maildomain_custom_attributes", "") + maildomain_attrs = [k.strip() for k in raw.split(",") if k.strip()] or None + + if user_email: + return self._list_by_user(user_email, maildomain_attrs) + if email: + return self._list_by_email(email, maildomain_attrs) + + return Response( + {"detail": "Provide either 'user_email' or 'email' query parameter."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + def _list_by_user(self, user_email, maildomain_attrs=None): + accesses = ( + models.MailboxAccess.objects.filter(user__email=user_email) + .select_related("mailbox__domain", "mailbox__contact") + .prefetch_related("mailbox__accesses__user") + ) + + results = [ + _serialize_mailbox_with_users( + access.mailbox, + role=MailboxRoleChoices(access.role).label, + maildomain_custom_attributes=maildomain_attrs, + ) + for access in accesses + ] + return Response({"results": results}) + + def _list_by_email(self, email, maildomain_attrs=None): + if "@" not in email: + return Response({"results": []}) + + local_part, domain_name = email.rsplit("@", 1) + mailboxes = ( + models.Mailbox.objects.filter( + local_part=local_part, domain__name=domain_name + ) + .select_related("domain", "contact") + .prefetch_related("accesses__user") + ) + + results = [ + _serialize_mailbox_with_users( + mailbox, maildomain_custom_attributes=maildomain_attrs + ) + for mailbox in mailboxes + ] + return Response({"results": results}) diff --git a/src/backend/core/api/viewsets/submit.py b/src/backend/core/api/viewsets/submit.py new file mode 100644 index 00000000..605d3116 --- /dev/null +++ b/src/backend/core/api/viewsets/submit.py @@ -0,0 +1,196 @@ +"""Generic outbound email submission endpoint. + +POST /api/v1.0/submit/ +Accepts a raw RFC 5322 message and sends it from a mailbox. +Creates a Message via the inbound pipeline (with ``is_outbound=True``), +then runs ``prepare_outbound_message`` synchronously (DKIM signing, blob +creation) and dispatches SMTP delivery asynchronously via Celery. +""" + +import logging + +from django.core.exceptions import ValidationError as DjangoValidationError + +from drf_spectacular.utils import extend_schema +from rest_framework import status +from rest_framework.exceptions import PermissionDenied +from rest_framework.response import Response +from rest_framework.views import APIView + +from core import models +from core.api.authentication import ChannelApiKeyAuthentication +from core.api.permissions import channel_scope +from core.enums import MAILBOX_ROLES_CAN_SEND, ChannelApiKeyScope +from core.mda.inbound_create import _create_message_from_inbound +from core.mda.outbound import prepare_outbound_message +from core.mda.outbound_tasks import send_message_task +from core.mda.rfc5322 import EmailParseError, parse_email_message + +logger = logging.getLogger(__name__) + + +class SubmitRawEmailView(APIView): + """Submit a pre-composed RFC 5322 email for delivery from a mailbox. + + POST /api/v1.0/submit/ + Content-Type: message/rfc822 + Headers: + X-Channel-Id: (api_key channel with messages:send scope) + X-API-Key: + X-Mail-From: (UUID of the sending mailbox) + X-Rcpt-To: [,] (comma-separated recipient addresses) + + The endpoint creates a Message record, DKIM-signs the raw MIME + synchronously, and dispatches SMTP delivery via Celery. + + Returns: ``{"message_id": "<…>", "status": "accepted"}`` (HTTP 202). + """ + + authentication_classes = [ChannelApiKeyAuthentication] + permission_classes = [channel_scope(ChannelApiKeyScope.MESSAGES_SEND)] + + @extend_schema(exclude=True) + def post(self, request): + """Accept a raw MIME message, create a Message, sign, and dispatch.""" + mailbox_id = request.META.get("HTTP_X_MAIL_FROM") + rcpt_to_header = request.META.get("HTTP_X_RCPT_TO") + + if not mailbox_id or not rcpt_to_header: + return Response( + {"detail": "Missing required headers: X-Mail-From, X-Rcpt-To."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Resolve mailbox + try: + mailbox = models.Mailbox.objects.select_related("domain").get(id=mailbox_id) + except (models.Mailbox.DoesNotExist, ValueError, DjangoValidationError): + return Response( + {"detail": "Mailbox not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + # Enforce the api_key channel's resource scope. A scope_level=mailbox + # credential can only send as that mailbox; a scope_level=maildomain + # credential only within that domain; a global credential is + # unrestricted. For scope_level=user we additionally require the + # target user to have a SENDER-or-better role on the mailbox via + # MailboxAccess — without this, a viewer-only user could mint a + # personal api_key and submit messages. + if not request.auth.api_key_covers( + mailbox=mailbox, mailbox_roles=MAILBOX_ROLES_CAN_SEND + ): + raise PermissionDenied("API key is not authorized to send as this mailbox.") + + # Parse envelope recipients. _create_message_from_inbound creates + # MessageRecipient rows from MIME To/Cc headers; any address that + # appears only in X-Rcpt-To (not in MIME headers) is added as BCC + # after message creation — this is how SMTP BCC works. + recipient_emails = [ + addr.strip() for addr in rcpt_to_header.split(",") if addr.strip() + ] + if not recipient_emails: + return Response( + {"detail": "X-Rcpt-To header is empty."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + raw_mime = request.body + if not raw_mime: + return Response( + {"detail": "Empty request body."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Parse to validate structure + try: + parsed = parse_email_message(raw_mime) + except EmailParseError: + return Response( + {"detail": "Failed to parse email message."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Validate sender matches the mailbox + sender_email = (parsed.get("from") or {}).get("email", "") + mailbox_email = str(mailbox) + if sender_email.lower() != mailbox_email.lower(): + return Response( + { + "detail": ( + f"From header '{sender_email}' does not match" + f" mailbox '{mailbox_email}'." + ) + }, + status=status.HTTP_403_FORBIDDEN, + ) + + # Create thread, contacts, message, and recipients from the parsed email. + # is_outbound=True skips blob creation (handled by prepare_outbound_message + # with DKIM) and AI features. + message = _create_message_from_inbound( + recipient_email=mailbox_email, + parsed_email=parsed, + raw_data=raw_mime, + mailbox=mailbox, + is_outbound=True, + ) + if not message: + return Response( + {"detail": "Failed to create message."}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + # Add envelope-only recipients as BCC. _create_message_from_inbound + # creates MessageRecipient rows from the MIME To/Cc/Bcc headers, but + # true BCC recipients appear only in the envelope (X-Rcpt-To), never + # in the MIME headers — that's how BCC works in SMTP. + mime_recipients = { + e.lower() + for e in message.recipients.values_list("contact__email", flat=True) + } + for addr in recipient_emails: + if addr.lower() not in mime_recipients: + try: + contact, _ = models.Contact.objects.get_or_create( + email=addr, + mailbox=mailbox, + defaults={"name": addr.split("@")[0]}, + ) + models.MessageRecipient.objects.get_or_create( + message=message, + contact=contact, + type=models.MessageRecipientTypeChoices.BCC, + ) + except Exception: # pylint: disable=broad-exception-caught + logger.warning("Failed to add BCC recipient (masked)") + + # Synchronous: validate recipients, throttle, DKIM sign, create blob. + # This is a one-shot API — clean up on any failure so no orphan + # draft remains. + try: + prepared = prepare_outbound_message( + mailbox, + message, + "", + "", + raw_mime=raw_mime, + ) + except Exception: + message.delete() + raise + + if not prepared: + message.delete() + return Response( + {"detail": "Failed to prepare message for sending."}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + # Dispatch async SMTP delivery + send_message_task.delay(str(message.id)) + + return Response( + {"message_id": str(message.id), "status": "accepted"}, + status=status.HTTP_202_ACCEPTED, + ) diff --git a/src/backend/core/apps.py b/src/backend/core/apps.py index 79b3b360..df32b608 100644 --- a/src/backend/core/apps.py +++ b/src/backend/core/apps.py @@ -1,7 +1,11 @@ """Messages Core application""" +import logging + from django.apps import AppConfig +logger = logging.getLogger(__name__) + class CoreConfig(AppConfig): """Configuration class for the messages core app.""" @@ -26,3 +30,11 @@ class CoreConfig(AppConfig): # Import signal handlers to register them # pylint: disable=unused-import, import-outside-toplevel import core.signals # noqa + + # Deprecation warning for legacy static API keys. + for deprecated in ("METRICS_API_KEY", "PROVISIONING_API_KEY"): + if getattr(settings, deprecated, None): + logger.warning( + "%s is set but deprecated and ignored. Migrate to a channel.", + deprecated, + ) diff --git a/src/backend/core/enums.py b/src/backend/core/enums.py index 009308a9..46147394 100644 --- a/src/backend/core/enums.py +++ b/src/backend/core/enums.py @@ -2,6 +2,8 @@ Core application enums declaration """ +from enum import StrEnum + from django.conf import global_settings from django.db import models @@ -141,6 +143,119 @@ class ThreadEventTypeChoices(models.TextChoices): IM = "im", "Instant message" +class ChannelScopeLevel(models.TextChoices): + """Scope level for a Channel: which resource the channel is bound to. + + - GLOBAL: instance-wide, no target. Creatable only via Django admin or CLI. + - MAILDOMAIN: bound to one MailDomain; actions limited to that domain. + - MAILBOX: bound to one Mailbox; actions limited to that mailbox. + - USER: personal channel bound to a User; actions limited to mailboxes + the user has MailboxAccess to. + """ + + GLOBAL = "global", "Global" + MAILDOMAIN = "maildomain", "Maildomain" + MAILBOX = "mailbox", "Mailbox" + USER = "user", "User" + + +class ChannelTypes(StrEnum): + """Known Channel.type values. + + ``StrEnum`` (not a Django ``TextChoices``): Channel.type is intentionally + a free-form CharField so adding a new type never requires a migration. + Members ARE strings (``ChannelTypes.MTA == "mta"``) so comparisons, + dict keys and ORM filters work transparently. + """ + + MTA = "mta" + WIDGET = "widget" + API_KEY = "api_key" + WEBHOOK = "webhook" + + +class WebhookEvents(StrEnum): + """Known webhook event identifiers. + + Stored as strings in ``Channel.settings["events"]``; validated by the + serializer at write time. Adding a new event is a Python-only change. + """ + + MESSAGE_RECEIVED = "message.received" + MESSAGE_SENT = "message.sent" + + +class ChannelApiKeyScope(models.TextChoices): + """Capability scopes granted to an api_key Channel. + + Stored as a list of string values in Channel.settings["scopes"] and + enforced by the serializer + HasChannelScope permission at the API layer. + Adding a new scope is a Python-only change (no DB choices, no migration). + + A credential's blast radius for any scope is automatically bounded by its + channel's scope_level + target FK: a scope_level=mailbox api_key can only + act on that mailbox, regardless of which scopes it holds. + + WRITE vs CREATE distinction: ``*_WRITE`` scopes modify an object the + channel already has resource-scope access to (e.g. archiving a thread in + a mailbox-scope channel's mailbox). ``*_CREATE`` scopes mint a brand-new + top-level resource, which is an escalation — these are global-only and + listed in ``CHANNEL_API_KEY_SCOPES_GLOBAL_ONLY``. Most resources only + need WRITE because their "create" never escalates; only mailboxes and + maildomains have a meaningful _CREATE counterpart. + + **Only scopes wired to a real endpoint live in this enum.** Adding a new + scope is a one-line change here once the endpoint exists. Forward-looking + scopes are sketched in the comment block below for design reference but + intentionally not enabled — having them in the enum without an enforcing + endpoint is dead surface area an attacker could probe. + """ + + METRICS_READ = "metrics:read", "Read usage metrics" + MAILBOXES_READ = "mailboxes:read", "Read mailboxes (and their users/roles)" + MESSAGES_SEND = "messages:send", "Send outbound messages" + MAILDOMAINS_CREATE = "maildomains:create", "Create new maildomains" + + # Forward-looking scopes — DO NOT uncomment without a real endpoint + # enforcing them. Listed here so the planned vocabulary is visible at a + # glance and so the WRITE/CREATE convention is documented. + # + # Reads: + # MAILDOMAINS_READ = "maildomains:read", "Read maildomains" + # USERS_READ = "users:read", "Read users" + # LABELS_READ = "labels:read", "Read labels" + # CONTACTS_READ = "contacts:read", "Read contacts" + # THREADS_READ = "threads:read", "Read thread metadata" + # MESSAGES_READ = "messages:read", "Read message metadata" + # MESSAGES_READ_BODY = "messages:read.body", "Read message bodies" + # ATTACHMENTS_READ = "attachments:read", "Read attachments" + # BLOBS_READ = "blobs:read", "Read raw MIME blobs" + # + # Writes (update an object the channel already has access to): + # MESSAGES_WRITE = "messages:write", "Create/modify drafts" + # THREADS_WRITE = "threads:write", "Archive/star/label" + # LABELS_WRITE = "labels:write", "Create/modify labels" + # CONTACTS_WRITE = "contacts:write", "Create/modify contacts" + # MAILBOXES_WRITE = "mailboxes:write", "Update existing mailboxes" + # MAILDOMAINS_WRITE = "maildomains:write", "Update existing maildomains" + # + # Creates (mint a brand-new top-level object — global-only): + # MAILBOXES_CREATE = "mailboxes:create", "Create new mailboxes" + + +# Scopes that can only be granted to / used by a scope_level=global Channel. +# Two enforcement points use this set: +# - the serializer (write time) rejects non-global channels asking for these +# - HasChannelScope (request time) rejects requests where the calling +# channel is not global but a global-only scope is required +CHANNEL_API_KEY_SCOPES_GLOBAL_ONLY = frozenset( + { + ChannelApiKeyScope.METRICS_READ.value, + ChannelApiKeyScope.MAILDOMAINS_CREATE.value, + } +) + + class MessageTemplateTypeChoices(models.IntegerChoices): """Defines the possible types of message templates.""" diff --git a/src/backend/core/factories.py b/src/backend/core/factories.py index 4deed39d..305107c9 100644 --- a/src/backend/core/factories.py +++ b/src/backend/core/factories.py @@ -3,6 +3,7 @@ Core application factories """ import json +import uuid from django.conf import settings from django.contrib.auth.hashers import make_password @@ -258,7 +259,15 @@ class AttachmentFactory(factory.django.DjangoModelFactory): class ChannelFactory(factory.django.DjangoModelFactory): - """A factory to create channels for testing purposes.""" + """A factory to create channels for testing purposes. + + ``scope_level`` is derived from the FKs by default: + - mailbox set → "mailbox" (default) + - maildomain set → "maildomain" + - user set → "user" + - none of the above → "global" + Callers can always override ``scope_level=`` explicitly. + """ class Meta: model = models.Channel @@ -267,6 +276,53 @@ class ChannelFactory(factory.django.DjangoModelFactory): type = factory.fuzzy.FuzzyChoice(["widget", "mta"]) settings = factory.Dict({"config": {"enabled": True}}) mailbox = factory.SubFactory(MailboxFactory) + maildomain = None + user = None + scope_level = factory.LazyAttribute( + lambda o: ( + "mailbox" + if o.mailbox is not None + else "maildomain" + if o.maildomain is not None + else "user" + if o.user is not None + else "global" + ) + ) + + +def make_api_key_channel( + *, + scope_level=enums.ChannelScopeLevel.GLOBAL, + scopes=(), + mailbox=None, + maildomain=None, + user=None, + name=None, + extra_settings=None, +): + """Create an api_key Channel and return ``(channel, plaintext)``. + + Single source of truth for tests that need a working api_key. Mints + the secret via ``Channel.rotate_api_key`` so the production helper is + exercised on the same code path the DRF/admin flows use, and so the + plaintext prefix stays consistent across production and tests. + + Returned plaintext is the only chance to capture it — the row's + ``encrypted_settings.api_key_hashes`` only stores the SHA-256 digest. + """ + channel = models.Channel( + name=name or f"test-{uuid.uuid4().hex[:6]}", + type=enums.ChannelTypes.API_KEY, + scope_level=scope_level, + mailbox=mailbox, + maildomain=maildomain, + user=user, + settings={"scopes": list(scopes), **(extra_settings or {})}, + ) + plaintext = channel.rotate_api_key(save=False) + channel.save() + return channel, plaintext class BlobFactory(factory.django.DjangoModelFactory): diff --git a/src/backend/core/mda/inbound.py b/src/backend/core/mda/inbound.py index a5721772..5ca2ccf0 100644 --- a/src/backend/core/mda/inbound.py +++ b/src/backend/core/mda/inbound.py @@ -208,7 +208,7 @@ def deliver_inbound_message( try_send_autoreply(mailbox, parsed_email, result) - return result is not None + return bool(result) # Regular messages: queue for spam processing try: diff --git a/src/backend/core/mda/inbound_create.py b/src/backend/core/mda/inbound_create.py index a0067d26..942e09fb 100644 --- a/src/backend/core/mda/inbound_create.py +++ b/src/backend/core/mda/inbound_create.py @@ -179,7 +179,7 @@ def _find_thread_by_message_ids( return None -def _create_message_from_inbound( +def _create_message_from_inbound( # pylint: disable=too-many-arguments recipient_email: str, parsed_email: Dict[str, Any], raw_data: bytes, @@ -190,14 +190,21 @@ def _create_message_from_inbound( imap_flags: Optional[List[str]] = None, channel: Optional[models.Channel] = None, is_spam: bool = False, + is_outbound: bool = False, ) -> Optional[models.Message]: - """Create a message and thread from inbound message data. + """Create a message and thread from parsed email data. + + Used for inbound delivery, imports, and outbound submission. + Returns the created Message on success, or None on failure. + Callers that only need a boolean can check truthiness of the return value. + + When ``is_outbound`` is True: + - ``is_sender`` is forced to True + - No blob is created (the caller handles DKIM signing + blob via prepare_outbound_message) + - AI features (summary, auto-labels) are skipped + - The message is created as a draft (finalized later by prepare_outbound_message) Warning: messages imported here could be is_sender=True. - - This method continues the logic of deliver_inbound_message, potentially asynchronously. - - TODO: continue splitting this into smaller methods. """ # pylint: disable=too-many-locals,too-many-branches,too-many-statements message_flags = {} @@ -329,18 +336,24 @@ def _create_message_from_inbound( mime_id=parsed_email.get("in_reply_to"), thread=thread ).first() - blob = mailbox.create_blob( - content=raw_data, - content_type="message/rfc822", - ) + # Outbound: no blob yet — prepare_outbound_message handles + # DKIM signing and blob creation later. + blob = None + if not is_outbound: + blob = mailbox.create_blob( + content=raw_data, + content_type="message/rfc822", + ) # Truncate subject to 255 characters if it exceeds max_length subject = parsed_email.get("subject") if subject and len(subject) > 255: subject = subject[:255] - is_sender = (is_import and is_import_sender) or ( - sender_email == recipient_email + is_sender = ( + is_outbound + or (is_import and is_import_sender) + or (sender_email == recipient_email) ) message = models.Message.objects.create( @@ -351,8 +364,10 @@ def _create_message_from_inbound( mime_id=parsed_email.get("messageId", parsed_email.get("message_id")) or None, parent=parent_message, - sent_at=parsed_email.get("date") or timezone.now(), - is_draft=False, + sent_at=( + None if is_outbound else (parsed_email.get("date") or timezone.now()) + ), + is_draft=is_outbound, # Outbound: draft until prepare_outbound_message finalizes is_sender=is_sender, is_trashed=False, is_spam=is_spam, @@ -511,8 +526,8 @@ def _create_message_from_inbound( thread.snippet = new_snippet thread.save(update_fields=["snippet"]) - # Do not trigger AI features on import or spam - if not is_import and not is_spam: + # Do not trigger AI features on import, spam, or outbound + if not is_import and not is_spam and not is_outbound: # Update summary if needed is ai is enabled if is_ai_summary_enabled(): messages = get_messages_from_thread(thread) diff --git a/src/backend/core/mda/outbound.py b/src/backend/core/mda/outbound.py index a658127e..466fe8d3 100644 --- a/src/backend/core/mda/outbound.py +++ b/src/backend/core/mda/outbound.py @@ -46,6 +46,38 @@ RETRY_INTERVALS = [ ] +def validate_mime_size(mime_size: int, message_id: str) -> None: + """Raise a ValidationError if *mime_size* exceeds the outgoing MIME limit.""" + max_total_size = settings.MAX_OUTGOING_BODY_SIZE + ( + settings.MAX_OUTGOING_ATTACHMENT_SIZE * 1.4 + ) + if mime_size > max_total_size: + mime_mb = mime_size / (1024 * 1024) + max_mb = max_total_size / (1024 * 1024) + + logger.error( + "MIME for message %s exceeds size limit: %d bytes (%.1f MB) > %d bytes (%.0f MB)", + message_id, + mime_size, + mime_mb, + max_total_size, + max_mb, + ) + + raise drf.exceptions.ValidationError( + { + "message": ( + "The email (%(mime_size)s MB) exceeds the maximum allowed " + "size of %(max_size)s MB." + ) + % { + "mime_size": f"{mime_mb:.1f}", + "max_size": f"{max_mb:.0f}", + } + } + ) + + def validate_attachments_size(total_size: int, message_id: str) -> None: """Raise a ValidationError if *total_size* exceeds the outgoing limit.""" if total_size > settings.MAX_OUTGOING_ATTACHMENT_SIZE: @@ -240,8 +272,14 @@ def prepare_outbound_message( text_body: str, html_body: str, user: Optional[models.User] = None, + raw_mime: Optional[bytes] = None, ) -> bool: - """Compose and sign an existing draft Message object before sending via SMTP. + """Prepare a Message for outbound delivery: compose (or accept raw) MIME, + sign with DKIM, create a blob, and mark the message as non-draft. + + When ``raw_mime`` is provided (e.g. from a raw MIME submission), + the MIME composition step is skipped and the raw bytes are used directly. + Validation, throttling, DKIM signing, and blob creation still apply. This part is called synchronously from the API view. """ @@ -268,6 +306,16 @@ def prepare_outbound_message( message=message, ) + if raw_mime is not None: + # Raw MIME path: the caller already composed the MIME. + validate_mime_size(len(raw_mime), message.id) + message.sender_user = user + message.blob = _sign_and_store_blob(mailbox_sender, raw_mime) + _finalize_sent_message(mailbox_sender, message) + return True + + # --- Web/API path: compose MIME from text/html body --- # + # TODO: Fetch MIME IDs of "references" from the thread # references = message.thread.messages.exclude(id=message.id).order_by("-created_at").all() @@ -322,64 +370,22 @@ def prepare_outbound_message( logger.error("Failed to compose MIME for message %s: %s", message.id, e) return False - # Validate the composed MIME size - mime_size = message.blob.size - max_total_size = settings.MAX_OUTGOING_BODY_SIZE + ( - settings.MAX_OUTGOING_ATTACHMENT_SIZE * 1.4 - ) - if mime_size > max_total_size: - mime_mb = mime_size / (1024 * 1024) - max_mb = max_total_size / (1024 * 1024) - - logger.error( - "Composed MIME for message %s exceeds size limit: %d bytes (%.1f MB) > %d bytes (%.0f MB)", - message.id, - mime_size, - mime_mb, - max_total_size, - max_mb, - ) - - raise drf.exceptions.ValidationError( - { - "message": ( - "The composed email (%(mime_size)s MB) exceeds the maximum allowed size of %(max_size)s MB. " - "Please reduce message content or attachments." - ) - % { - "mime_size": f"{mime_mb:.1f}", - "max_size": f"{max_mb:.0f}", - } - } - ) + # compose_and_store_mime already DKIM-signed and stored the blob. + # Validate the final size — clean up the blob if it's too large. + try: + validate_mime_size(message.blob.size, message.id) + except drf.exceptions.ValidationError: + message.blob.delete() + raise draft_blob = message.draft_blob - message.is_draft = False message.sender_user = user - message.draft_blob = None - message.created_at = timezone.now() - message.updated_at = timezone.now() - message.save( - update_fields=[ - "updated_at", - "blob", - "mime_id", - "is_draft", - "sender_user", - "draft_blob", - "has_attachments", - "created_at", - ] + # has_attachments is already set by compose_and_store_mime (includes + # inline signature images), so we do not overwrite it here. + _finalize_sent_message( + mailbox_sender, message, extra_update_fields=("mime_id", "has_attachments") ) - # Mark the thread as read for the sender — they've obviously seen - # their own message, so read_at must be >= messaged_at. - models.ThreadAccess.objects.filter( - thread=message.thread, - mailbox=mailbox_sender, - ).update(read_at=message.created_at) - - message.thread.update_stats() # Clean up the draft blob and the attachment blobs if draft_blob: @@ -392,6 +398,56 @@ def prepare_outbound_message( return True +def _sign_and_store_blob( + mailbox_sender: models.Mailbox, raw_mime: bytes +) -> models.Blob: + """DKIM-sign raw MIME bytes and persist them as a blob on the mailbox.""" + dkim_signature_header: Optional[bytes] = sign_message_dkim( + raw_mime_message=raw_mime, maildomain=mailbox_sender.domain + ) + + raw_mime_signed = raw_mime + if dkim_signature_header: + raw_mime_signed = dkim_signature_header + b"\r\n" + raw_mime + + return mailbox_sender.create_blob( + content=raw_mime_signed, + content_type="message/rfc822", + ) + + +def _finalize_sent_message( + mailbox_sender: models.Mailbox, + message: models.Message, + extra_update_fields: tuple = (), +) -> None: + """Finalize an outbound message once its blob is attached: clear draft + state, stamp timestamps, save, mark the thread as read for the sender, + and refresh thread stats.""" + message.is_draft = False + message.draft_blob = None + message.created_at = timezone.now() + message.updated_at = timezone.now() + + update_fields = [ + "updated_at", + "blob", + "is_draft", + "sender_user", + "draft_blob", + "created_at", + *extra_update_fields, + ] + message.save(update_fields=update_fields) + + models.ThreadAccess.objects.filter( + thread=message.thread, + mailbox=mailbox_sender, + ).update(read_at=message.created_at) + + message.thread.update_stats() + + def send_message(message: models.Message, force_mta_out: bool = False): """Send an existing Message, internally or externally. diff --git a/src/backend/core/migrations/0024_channel_encrypted_settings_scope_level.py b/src/backend/core/migrations/0024_channel_encrypted_settings_scope_level.py new file mode 100644 index 00000000..2f310f8d --- /dev/null +++ b/src/backend/core/migrations/0024_channel_encrypted_settings_scope_level.py @@ -0,0 +1,192 @@ +"""Channel: encrypted_settings + scope_level + user + last_used_at. + +Single schema change for this PR. + +- Adds ``encrypted_settings`` (EncryptedJSONField) for per-type secrets. +- Adds ``user`` (FK to User, SET_NULL). Set on scope_level=user channels — + the User the channel is bound to. Otherwise an optional creator-audit + pointer (the user who created the row via DRF). May be NULL on any + scope. Personal channels are explicitly deleted by a pre_delete signal + in core.signals before the User is removed; the FK is SET_NULL rather + than CASCADE so a future constraint relaxation cannot silently sweep up + unrelated channels. +- Adds ``scope_level`` (global / maildomain / mailbox / user) with a backfill + from the existing mailbox/maildomain FKs. +- Adds ``last_used_at`` for operational metadata. +- Drops the ``type`` field's ``default="mta"``: every supported caller + (DRF serializer, factories, admin) now passes ``type`` explicitly, and + the implicit default was a bypass for FEATURE_MAILBOX_ADMIN_CHANNELS. +- Replaces the old ``channel_has_target`` XOR constraint with a + scope-level-driven ``channel_scope_level_targets`` check. +""" + +import django.db.models.deletion +import encrypted_fields.fields +from django.conf import settings +from django.db import migrations, models + + +def backfill_scope_level(apps, schema_editor): + """Set scope_level on every existing Channel row based on its FKs. + + All existing rows have exactly one of mailbox/maildomain populated (the + old XOR constraint guaranteed that), so scope_level is derived + unambiguously. + """ + Channel = apps.get_model("core", "Channel") + Channel.objects.filter(mailbox__isnull=False).update(scope_level="mailbox") + Channel.objects.filter(maildomain__isnull=False).update(scope_level="maildomain") + + +def noop_reverse(apps, schema_editor): + """No reverse — scope_level is dropped by AddField reversal.""" + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0023_threadevent"), + ] + + operations = [ + # -- drop the implicit "mta" default on Channel.type ------------------ + # The default was a silent FEATURE_MAILBOX_ADMIN_CHANNELS bypass: + # a nested mailbox/user POST that omitted ``type`` would land an + # "mta" channel even when "mta" was not in the allowlist. Every + # supported caller (DRF serializer, factories, admin) now passes + # ``type`` explicitly; the field is therefore required at the DB + # layer too, so any future regression fails loudly on INSERT. + migrations.AlterField( + model_name="channel", + name="type", + field=models.CharField( + help_text="Type of channel", + max_length=255, + verbose_name="type", + ), + ), + # -- encrypted_settings + creator user -------------------------------- + migrations.AddField( + model_name="channel", + name="encrypted_settings", + field=encrypted_fields.fields.EncryptedJSONField( + blank=True, + default=dict, + help_text="Encrypted channel settings (e.g., app-specific passwords)", + verbose_name="encrypted settings", + ), + ), + # Set on scope_level=user channels — the User the channel is bound + # to; NULL for any other scope level. SET_NULL rather than CASCADE + # so a future constraint relaxation can never silently sweep up + # unrelated channels; user-scope channels are explicitly deleted by + # the pre_delete signal in core.signals before the User row is + # removed. + migrations.AddField( + model_name="channel", + name="user", + field=models.ForeignKey( + blank=True, + help_text=( + "User who owns (scope_level=user) or created (audit) " + "this channel" + ), + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="channels", + to=settings.AUTH_USER_MODEL, + ), + ), + # -- scope_level + last_used_at --------------------------------------- + migrations.AddField( + model_name="channel", + name="scope_level", + field=models.CharField( + choices=[ + ("global", "Global"), + ("maildomain", "Maildomain"), + ("mailbox", "Mailbox"), + ("user", "User"), + ], + db_index=True, + help_text=( + "Resource scope the channel is bound to: 'global' " + "(instance-wide, no target — admin/CLI only), 'maildomain', " + "'mailbox', or 'user' (personal channel bound to ``user``)." + ), + max_length=16, + null=True, + verbose_name="scope level", + ), + ), + migrations.AddField( + model_name="channel", + name="last_used_at", + field=models.DateTimeField( + blank=True, + db_index=True, + help_text=( + "Operational timestamp updated (throttled) whenever the " + "channel is used." + ), + null=True, + verbose_name="last used at", + ), + ), + migrations.RunPython(backfill_scope_level, noop_reverse), + migrations.AlterField( + model_name="channel", + name="scope_level", + field=models.CharField( + choices=[ + ("global", "Global"), + ("maildomain", "Maildomain"), + ("mailbox", "Mailbox"), + ("user", "User"), + ], + db_index=True, + help_text=( + "Resource scope the channel is bound to: 'global' " + "(instance-wide, no target — admin/CLI only), 'maildomain', " + "'mailbox', or 'user' (personal channel bound to ``user``)." + ), + max_length=16, + verbose_name="scope level", + ), + ), + # -- swap XOR check constraint for scope-level-driven one ------------- + migrations.RemoveConstraint( + model_name="channel", + name="channel_has_target", + ), + migrations.AddConstraint( + model_name="channel", + constraint=models.CheckConstraint( + # ``user`` is permitted on any scope as a creator-audit FK. + # Only the user-scope clause requires it NOT NULL (target). + condition=( + ( + models.Q(scope_level="global") + & models.Q(mailbox__isnull=True) + & models.Q(maildomain__isnull=True) + ) + | ( + models.Q(scope_level="maildomain") + & models.Q(mailbox__isnull=True) + & models.Q(maildomain__isnull=False) + ) + | ( + models.Q(scope_level="mailbox") + & models.Q(mailbox__isnull=False) + & models.Q(maildomain__isnull=True) + ) + | ( + models.Q(scope_level="user") + & models.Q(mailbox__isnull=True) + & models.Q(maildomain__isnull=True) + & models.Q(user__isnull=False) + ) + ), + name="channel_scope_level_targets", + ), + ), + ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 42e06f39..7577cfab 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -7,6 +7,7 @@ import base64 import hashlib import json import re +import secrets import uuid from datetime import datetime as dt from datetime import time, timedelta @@ -29,10 +30,11 @@ from django.utils.text import slugify import jsonschema import pyzstd -from encrypted_fields.fields import EncryptedTextField +from encrypted_fields.fields import EncryptedJSONField, EncryptedTextField from timezone_field import TimeZoneField from core.enums import ( + ChannelScopeLevel, CompressionTypeChoices, CRUDAbilities, DKIMAlgorithmChoices, @@ -438,8 +440,18 @@ class Channel(BaseModel): "name", max_length=255, help_text="Human-readable name for this channel" ) - type = models.CharField( - "type", max_length=255, help_text="Type of channel", default="mta" + type = models.CharField("type", max_length=255, help_text="Type of channel") + + scope_level = models.CharField( + "scope level", + max_length=16, + choices=ChannelScopeLevel.choices, + db_index=True, + help_text=( + "Resource scope the channel is bound to: 'global' (instance-wide, " + "no target — admin/CLI only), 'maildomain', 'mailbox', or 'user' " + "(personal channel bound to ``user``)." + ), ) settings = models.JSONField( @@ -458,6 +470,33 @@ class Channel(BaseModel): help_text="Mailbox that receives messages from this channel", ) + encrypted_settings = EncryptedJSONField( + "encrypted settings", + default=dict, + blank=True, + help_text="Encrypted channel settings (e.g., app-specific passwords)", + ) + + # Dual-purpose FK: + # - For ``scope_level=user`` channels, this is the target user the + # channel is bound to. The check constraint forces it NOT NULL. + # - For every other scope level, this is the creator audit (the user + # who created the channel via DRF). May be NULL for channels created + # by the CLI / Django admin / data migration. + # The FK uses SET_NULL rather than CASCADE so a user delete cannot + # blanket-cascade unrelated channels; user-scope channels are + # explicitly deleted by the pre_delete signal in core.signals before + # the User row is removed (otherwise SET_NULL would null user_id on a + # user-scope row and immediately violate the check constraint). + user = models.ForeignKey( + "User", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="channels", + help_text="User who owns (scope_level=user) or created (audit) this channel", + ) + maildomain = models.ForeignKey( "MailDomain", on_delete=models.CASCADE, @@ -467,6 +506,16 @@ class Channel(BaseModel): help_text="Mail domain that owns this channel", ) + last_used_at = models.DateTimeField( + "last used at", + null=True, + blank=True, + db_index=True, + help_text=( + "Operational timestamp updated (throttled) whenever the channel is used." + ), + ) + class Meta: db_table = "messages_channel" verbose_name = "channel" @@ -475,15 +524,148 @@ class Channel(BaseModel): constraints = [ models.CheckConstraint( check=( - models.Q(mailbox__isnull=False) ^ models.Q(maildomain__isnull=False) + # The constraint enforces the mailbox/maildomain shape + # for each scope level. ``user`` is NOT in any of these + # clauses on purpose: it can be set on any scope as a + # creator-audit FK. The only place ``user`` shows up is + # the user-scope clause, where it must be NOT NULL + # (it's the target). + ( + Q(scope_level=ChannelScopeLevel.GLOBAL) + & Q(mailbox__isnull=True) + & Q(maildomain__isnull=True) + ) + | ( + Q(scope_level=ChannelScopeLevel.MAILDOMAIN) + & Q(mailbox__isnull=True) + & Q(maildomain__isnull=False) + ) + | ( + Q(scope_level=ChannelScopeLevel.MAILBOX) + & Q(mailbox__isnull=False) + & Q(maildomain__isnull=True) + ) + | ( + Q(scope_level=ChannelScopeLevel.USER) + & Q(mailbox__isnull=True) + & Q(maildomain__isnull=True) + & Q(user__isnull=False) + ) ), - name="channel_has_target", + name="channel_scope_level_targets", ), ] def __str__(self): return self.name + # The scope_level ↔ target invariant is enforced by the + # ``channel_scope_level_targets`` CheckConstraint above. BaseModel.save + # calls full_clean(), which calls validate_constraints(), which evaluates + # that Q() in Python and raises ValidationError before the row is sent + # to the DB. No custom clean() override is needed. + + # --- api_key helpers --- # + + API_KEY_PREFIX = "msgk_" + + def rotate_api_key(self, *, save: bool = True) -> str: + """Mint a fresh api_key plaintext, replace ``api_key_hashes`` with + the new SHA-256 digest, and return the plaintext exactly once. + + Single-active rotation: any prior secret is invalidated immediately. + Dual-active "smooth" rotation (appending without removing) is not + exposed here — callers that need it must mutate ``encrypted_settings`` + directly via the Django admin. + + Set ``save=False`` for the DRF create path, where the row is being + built and ``super().create()`` will persist it shortly after. + """ + plaintext = self.API_KEY_PREFIX + secrets.token_urlsafe(32) + digest = hashlib.sha256(plaintext.encode("utf-8")).hexdigest() + self.encrypted_settings = { + **(self.encrypted_settings or {}), + "api_key_hashes": [digest], + } + if save: + self.save(update_fields=["encrypted_settings", "updated_at"]) + return plaintext + + def api_key_covers( + self, *, mailbox=None, maildomain=None, mailbox_roles=None + ) -> bool: + """Return True if an action on the given resource is within this + channel's scope, assuming the channel is an api_key and the scope + check has already passed. + + - Global channels cover everything. + - Maildomain channels cover any mailbox in their domain. + - Mailbox channels cover only their mailbox. + - User channels cover any mailbox the target user has access to via + ``MailboxAccess`` — and crucially, only if that access carries a + role in ``mailbox_roles`` when the kwarg is supplied. This is what + stops a viewer-only user from submitting via a personal api_key: + /submit/ passes ``MAILBOX_ROLES_CAN_SEND``, so a VIEWER access is + rejected here. + + For mailbox / maildomain / global scopes the channel was bound by an + admin who already had authority over the resource, so the role check + does not apply — the api_key inherits the binding directly. + + ``mailbox_roles`` is ignored for non-user scopes. + """ + # Fail closed on ambiguous input: callers must ask about exactly one + # resource at a time, otherwise branch order below would silently + # decide which one "wins". + if mailbox is not None and maildomain is not None: + return False + if self.scope_level == ChannelScopeLevel.GLOBAL: + return True + if self.scope_level == ChannelScopeLevel.MAILDOMAIN: + if maildomain is not None: + return maildomain.id == self.maildomain_id + if mailbox is not None: + return mailbox.domain_id == self.maildomain_id + return False + if self.scope_level == ChannelScopeLevel.MAILBOX: + if mailbox is not None: + return mailbox.id == self.mailbox_id + return False + if self.scope_level == ChannelScopeLevel.USER: + if mailbox is None: + return False + qs = MailboxAccess.objects.filter(user_id=self.user_id, mailbox=mailbox) + if mailbox_roles is not None: + qs = qs.filter(role__in=mailbox_roles) + return qs.exists() + return False + + def mark_used(self, only_if_older_than_seconds: int = 300): + """Throttled update of last_used_at. + + The throttle predicate is evaluated in the DB, not in Python: + ``filter(pk=..., Q(last_used_at < cutoff) | Q(last_used_at IS NULL)) + .update(last_used_at=now)``. This means concurrent requests racing + on the same channel coalesce into a single UPDATE — only the first + one matches, the others see the freshly written timestamp and + affect zero rows. Filtering on ``self.last_used_at`` in Python + would not give that guarantee because each worker would see a + stale in-memory value and all of them would issue the UPDATE. + + We deliberately go through ``filter().update()`` rather than + ``self.save()`` to skip full_clean() on global channels and to + avoid racing with other writers on adjacent fields. + """ + now = timezone.now() + cutoff = now - timedelta(seconds=only_if_older_than_seconds) + rows_affected = ( + Channel.objects.filter(pk=self.pk) + .filter(Q(last_used_at__lt=cutoff) | Q(last_used_at__isnull=True)) + .update(last_used_at=now) + ) + if rows_affected: + self.last_used_at = now + class Mailbox(BaseModel): """Mailbox model to store mailbox information.""" diff --git a/src/backend/core/services/search/mapping.py b/src/backend/core/services/search/mapping.py index e5a08b78..be2b7759 100644 --- a/src/backend/core/services/search/mapping.py +++ b/src/backend/core/services/search/mapping.py @@ -1,7 +1,14 @@ """OpenSearch index and mapping configuration.""" -# Index name constants -MESSAGE_INDEX = "messages" +import os + +# Index name constants. When running under pytest-xdist, give every worker its +# own index so parallel test workers do not race on the same shared index +# (create/delete/index operations would otherwise step on each other and +# surface as flaky `resource_already_exists_exception` / missing-doc errors). +# In production PYTEST_XDIST_WORKER is unset, so the name stays "messages". +_XDIST_WORKER = os.environ.get("PYTEST_XDIST_WORKER", "") +MESSAGE_INDEX = f"messages_{_XDIST_WORKER}" if _XDIST_WORKER else "messages" # Schema definitions MESSAGE_MAPPING = { diff --git a/src/backend/core/signals.py b/src/backend/core/signals.py index 004ac6c0..e0a6dd43 100644 --- a/src/backend/core/signals.py +++ b/src/backend/core/signals.py @@ -9,6 +9,7 @@ from django.db.models.signals import post_delete, post_save, pre_delete from django.dispatch import receiver from core import models +from core.enums import ChannelScopeLevel from core.services.identity.keycloak import ( sync_mailbox_to_keycloak_user, sync_maildomain_to_keycloak_group, @@ -54,7 +55,7 @@ def sync_mailbox_to_keycloak(sender, instance, created, **kwargs): @receiver(post_save, sender=models.Message) def index_message_post_save(sender, instance, created, **kwargs): """Index a message after it's saved.""" - if not getattr(settings, "OPENSEARCH_INDEX_THREADS", False): + if not settings.OPENSEARCH_INDEX_THREADS: return try: @@ -74,7 +75,7 @@ def index_message_post_save(sender, instance, created, **kwargs): @receiver(post_save, sender=models.MessageRecipient) def index_message_recipient_post_save(sender, instance, created, **kwargs): """Index a message recipient after it's saved.""" - if not getattr(settings, "OPENSEARCH_INDEX_THREADS", False): + if not settings.OPENSEARCH_INDEX_THREADS: return try: @@ -132,7 +133,7 @@ def update_thread_stats_on_delivery_status_change(sender, instance, **kwargs): @receiver(post_save, sender=models.Thread) def index_thread_post_save(sender, instance, created, **kwargs): """Index a thread after it's saved.""" - if not getattr(settings, "OPENSEARCH_INDEX_THREADS", False): + if not settings.OPENSEARCH_INDEX_THREADS: return try: @@ -167,7 +168,7 @@ def delete_message_blobs(sender, instance, **kwargs): @receiver(post_delete, sender=models.Message) def delete_message_from_index(sender, instance, **kwargs): """Remove a message from the index after it's deleted.""" - if not getattr(settings, "OPENSEARCH_INDEX_THREADS", False): + if not settings.OPENSEARCH_INDEX_THREADS: return try: @@ -191,7 +192,7 @@ def delete_message_from_index(sender, instance, **kwargs): @receiver(post_delete, sender=models.Thread) def delete_thread_from_index(sender, instance, **kwargs): """Remove a thread and its messages from the index after it's deleted.""" - if not getattr(settings, "OPENSEARCH_INDEX_THREADS", False): + if not settings.OPENSEARCH_INDEX_THREADS: return try: @@ -245,7 +246,7 @@ def delete_orphan_draft_attachments(sender, instance, **kwargs): @receiver(post_save, sender=models.ThreadAccess) def update_mailbox_flags_on_access_save(sender, instance, created, **kwargs): """Update mailbox flags in OpenSearch when ThreadAccess read/starred state changes.""" - if not getattr(settings, "OPENSEARCH_INDEX_THREADS", False): + if not settings.OPENSEARCH_INDEX_THREADS: return update_fields = kwargs.get("update_fields") @@ -271,7 +272,7 @@ def update_mailbox_flags_on_access_save(sender, instance, created, **kwargs): @receiver(post_delete, sender=models.ThreadAccess) def update_unread_mailboxes_on_access_delete(sender, instance, **kwargs): """Update unread_mailboxes in OpenSearch when a ThreadAccess is deleted.""" - if not getattr(settings, "OPENSEARCH_INDEX_THREADS", False): + if not settings.OPENSEARCH_INDEX_THREADS: return thread_id = str(instance.thread_id) @@ -286,3 +287,27 @@ def update_unread_mailboxes_on_access_delete(sender, instance, **kwargs): instance.thread_id, e, ) + + +@receiver(pre_delete, sender=models.User) +def delete_user_scope_channels_on_user_delete(sender, instance, **kwargs): + """Delete the user's personal (scope_level=user) Channels before the + user row is removed. + + Channel.user uses on_delete=SET_NULL deliberately — the FK alone must + not blanket-cascade, because a future relaxation of the + channel_scope_level_targets check constraint could otherwise let a + user delete silently sweep up unrelated channels. This handler is the + *only* place where user-scope channels are removed in response to a + user deletion. The query is filtered explicitly on + ``scope_level=user``, never on the FK alone. + + If we did not delete these rows here, SET_NULL on the FK would null + ``user_id`` on the user-scope rows, immediately violating the check + constraint and aborting the user delete with an IntegrityError — so + this signal is also load-bearing for user deletion to succeed at all. + """ + models.Channel.objects.filter( + user=instance, + scope_level=ChannelScopeLevel.USER, + ).delete() diff --git a/src/backend/core/templates/admin/_object_tools_button_styles.html b/src/backend/core/templates/admin/_object_tools_button_styles.html new file mode 100644 index 00000000..a55efbca --- /dev/null +++ b/src/backend/core/templates/admin/_object_tools_button_styles.html @@ -0,0 +1,28 @@ +{# Shared diff --git a/src/backend/core/templates/admin/core/channel/change_form.html b/src/backend/core/templates/admin/core/channel/change_form.html new file mode 100644 index 00000000..20d4cace --- /dev/null +++ b/src/backend/core/templates/admin/core/channel/change_form.html @@ -0,0 +1,24 @@ +{% extends "admin/change_form.html" %} +{% load i18n admin_urls %} + +{% block extrastyle %} +{{ block.super }} +{% include "admin/_object_tools_button_styles.html" %} +{% endblock %} + +{% block object-tools-items %} +{% if original and original.type == "api_key" %} +
  • +
    + {% csrf_token %} + +
    +
  • +{% endif %} +{{ block.super }} +{% endblock %} diff --git a/src/backend/core/templates/admin/core/channel/regenerated_api_key.html b/src/backend/core/templates/admin/core/channel/regenerated_api_key.html new file mode 100644 index 00000000..89a2c5d1 --- /dev/null +++ b/src/backend/core/templates/admin/core/channel/regenerated_api_key.html @@ -0,0 +1,28 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_urls %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +

    {% translate "New api_key generated" %}

    + +

    {% translate "Copy this value now. It will not be shown again." %}

    + +

    {% blocktranslate %}The previous secret has been invalidated immediately. Any client still using the old api_key will start failing on its next call.{% endblocktranslate %}

    + +
    {{ api_key }}
    + +

    + + {% translate "Back to channel" %} + +

    +{% endblock %} diff --git a/src/backend/core/templates/admin/core/maildomain/change_form.html b/src/backend/core/templates/admin/core/maildomain/change_form.html index 84d98576..3ffe2994 100644 --- a/src/backend/core/templates/admin/core/maildomain/change_form.html +++ b/src/backend/core/templates/admin/core/maildomain/change_form.html @@ -2,30 +2,7 @@ {% block extrastyle %} {{ block.super }} - +{% include "admin/_object_tools_button_styles.html" %} {% endblock %} {% load i18n admin_urls %} diff --git a/src/backend/core/tests/api/test_channel_api_key_auth.py b/src/backend/core/tests/api/test_channel_api_key_auth.py new file mode 100644 index 00000000..7d1a9e44 --- /dev/null +++ b/src/backend/core/tests/api/test_channel_api_key_auth.py @@ -0,0 +1,176 @@ +"""Tests for ChannelApiKeyAuthentication + HasChannelScope. + +Covers the cross-cutting auth/permission layer directly, independent of any +specific endpoint. Endpoint-specific scope enforcement is tested in each +viewset's own test file (test_submit, test_provisioning_*, test_*_metrics). +""" +# pylint: disable=missing-function-docstring,missing-class-docstring,import-outside-toplevel + +import hashlib +import uuid +from datetime import timedelta + +from django.utils import timezone + +import pytest + +from core import models +from core.enums import ChannelApiKeyScope, ChannelScopeLevel +from core.factories import MailboxFactory, make_api_key_channel + +SUBMIT_URL = "/api/v1.0/submit/" + + +def _make_channel(scopes=(ChannelApiKeyScope.MESSAGES_SEND.value,), **kwargs): + """Wrapper around the shared factory pre-loaded with the auth-class + test default scope (messages:send). Callers can still override + ``scopes`` and any other kwarg.""" + return make_api_key_channel(scopes=scopes, **kwargs) + + +@pytest.mark.django_db +class TestChannelApiKeyAuth: + """Direct edge-case coverage for the authentication class.""" + + def test_missing_headers_returns_401(self, client): + """No headers at all → DRF NotAuthenticated → 401.""" + response = client.post(SUBMIT_URL) + assert response.status_code == 401 + + def test_malformed_channel_id_returns_401(self, client): + response = client.post( + SUBMIT_URL, + HTTP_X_CHANNEL_ID="not-a-uuid", + HTTP_X_API_KEY="anything", + ) + assert response.status_code == 401 + + def test_wrong_secret_returns_401(self, client): + channel, _plaintext = _make_channel() + response = client.post( + SUBMIT_URL, + HTTP_X_CHANNEL_ID=str(channel.id), + HTTP_X_API_KEY="not-the-real-secret", + ) + assert response.status_code == 401 + + def test_expired_key_returns_401(self, client): + past = (timezone.now() - timedelta(days=1)).isoformat() + channel, plaintext = _make_channel(extra_settings={"expires_at": past}) + response = client.post( + SUBMIT_URL, + HTTP_X_CHANNEL_ID=str(channel.id), + HTTP_X_API_KEY=plaintext, + ) + assert response.status_code == 401 + + def test_non_api_key_channel_cannot_authenticate(self, client): + """A widget/mta channel with a hash in encrypted_settings must not + authenticate the api_key path — the authentication class filters by + type='api_key' explicitly.""" + plaintext = f"msg_test_{uuid.uuid4().hex}" + digest = hashlib.sha256(plaintext.encode("utf-8")).hexdigest() + channel = models.Channel( + name="not-api-key", + type="widget", + scope_level=ChannelScopeLevel.GLOBAL, + encrypted_settings={"api_key_hashes": [digest]}, + settings={"scopes": [ChannelApiKeyScope.MESSAGES_SEND.value]}, + ) + channel.save() + response = client.post( + SUBMIT_URL, + HTTP_X_CHANNEL_ID=str(channel.id), + HTTP_X_API_KEY=plaintext, + ) + assert response.status_code == 401 + + def test_last_used_at_updates_on_success(self, client): + """A successful auth call updates last_used_at within the throttle window.""" + channel, plaintext = _make_channel() + mailbox = MailboxFactory() + assert channel.last_used_at is None + + # We don't need the call to succeed end-to-end for the submit endpoint. + # We just need to reach the authentication class, which itself calls + # mark_used() on success. Submit without a body → 400, but auth has + # already passed by then. + response = client.post( + SUBMIT_URL, + HTTP_X_CHANNEL_ID=str(channel.id), + HTTP_X_API_KEY=plaintext, + HTTP_X_MAIL_FROM=str(mailbox.id), + HTTP_X_RCPT_TO="to@x.test", + ) + # Auth must have passed; the post-auth pipeline is allowed to + # produce 202 (accepted), 400 (empty body / validation), or 403 + # (scope mismatch). 500 is NOT accepted — a server error here + # would mask a real bug behind a "test still green" signal. + assert response.status_code in (202, 400, 403) + + channel.refresh_from_db() + # Only check that last_used_at moved past the None state when + # the auth call succeeded (scopes include messages:send + global). + assert channel.last_used_at is not None + + +@pytest.mark.django_db +class TestHasChannelScope: + """Direct tests for HasChannelScope.has_permission.""" + + def test_scope_present(self, rf): + channel, _ = _make_channel(scopes=(ChannelApiKeyScope.MESSAGES_SEND.value,)) + from core.api.permissions import channel_scope + + perm_class = channel_scope(ChannelApiKeyScope.MESSAGES_SEND) + request = rf.post("/") + request.auth = channel + assert perm_class().has_permission(request, None) is True + + def test_scope_absent(self, rf): + channel, _ = _make_channel(scopes=(ChannelApiKeyScope.METRICS_READ.value,)) + from core.api.permissions import channel_scope + + perm_class = channel_scope(ChannelApiKeyScope.MESSAGES_SEND) + request = rf.post("/") + request.auth = channel + assert perm_class().has_permission(request, None) is False + + def test_auth_not_a_channel(self, rf): + from core.api.permissions import channel_scope + + perm_class = channel_scope(ChannelApiKeyScope.MESSAGES_SEND) + request = rf.post("/") + request.auth = None + assert perm_class().has_permission(request, None) is False + + +@pytest.mark.django_db +class TestApiKeyCovers: + """Channel.api_key_covers resource-scope helper.""" + + def test_global_covers_everything(self): + channel, _ = _make_channel(scope_level=ChannelScopeLevel.GLOBAL) + mailbox = MailboxFactory() + assert channel.api_key_covers(mailbox=mailbox) is True + assert channel.api_key_covers(maildomain=mailbox.domain) is True + + def test_maildomain_covers_only_its_domain(self): + mailbox = MailboxFactory() + channel, _ = _make_channel( + scope_level=ChannelScopeLevel.MAILDOMAIN, + maildomain=mailbox.domain, + ) + assert channel.api_key_covers(mailbox=mailbox) is True + other = MailboxFactory() + assert channel.api_key_covers(mailbox=other) is False + + def test_mailbox_covers_only_itself(self): + mailbox = MailboxFactory() + channel, _ = _make_channel( + scope_level=ChannelScopeLevel.MAILBOX, + mailbox=mailbox, + ) + assert channel.api_key_covers(mailbox=mailbox) is True + other = MailboxFactory() + assert channel.api_key_covers(mailbox=other) is False diff --git a/src/backend/core/tests/api/test_channel_scope_level.py b/src/backend/core/tests/api/test_channel_scope_level.py new file mode 100644 index 00000000..e8e49fc5 --- /dev/null +++ b/src/backend/core/tests/api/test_channel_scope_level.py @@ -0,0 +1,1312 @@ +"""Tests for Channel.scope_level defense-in-depth. + +Every layer that is supposed to block a non-superadmin from creating a +scope_level=global row is exercised here. If the DB constraint is removed, +these tests still catch escalation at the ORM layer. If the ORM layer is +bypassed via a raw insert, the check constraint catches it. +""" +# pylint: disable=import-outside-toplevel,missing-function-docstring,too-many-lines + +from django.core.exceptions import ValidationError +from django.db import IntegrityError, transaction +from django.test import override_settings +from django.urls import reverse + +import pytest + +from core import models +from core.enums import ChannelScopeLevel, ChannelTypes +from core.factories import ( + MailboxAccessFactory, + MailboxFactory, + MailDomainFactory, + UserFactory, +) + +# -------------------------------------------------------------------------- # +# DB check constraint +# -------------------------------------------------------------------------- # + + +def _force_insert(**kwargs): + """Create a Channel bypassing BaseModel.save() / full_clean(). + + bulk_create skips save() and our ORM-level clean(), so the row is written + straight to PostgreSQL and only the DB check constraint can reject it. + This is what we want to exercise in this test class. + """ + return models.Channel.objects.bulk_create([models.Channel(**kwargs)]) + + +@pytest.mark.django_db +class TestScopeLevelCheckConstraint: + """PostgreSQL must reject inconsistent scope_level/target combinations.""" + + def test_global_with_mailbox_raises(self): + mailbox = MailboxFactory() + with transaction.atomic(): + with pytest.raises(IntegrityError): + _force_insert( + name="bad-global", + type=ChannelTypes.API_KEY, + scope_level=ChannelScopeLevel.GLOBAL, + mailbox=mailbox, + ) + + def test_global_with_maildomain_raises(self): + maildomain = MailDomainFactory() + with transaction.atomic(): + with pytest.raises(IntegrityError): + _force_insert( + name="bad-global2", + type=ChannelTypes.API_KEY, + scope_level=ChannelScopeLevel.GLOBAL, + maildomain=maildomain, + ) + + def test_mailbox_with_maildomain_raises(self): + mailbox = MailboxFactory() + maildomain = MailDomainFactory() + with transaction.atomic(): + with pytest.raises(IntegrityError): + _force_insert( + name="bad-mixed", + type=ChannelTypes.API_KEY, + scope_level=ChannelScopeLevel.MAILBOX, + mailbox=mailbox, + maildomain=maildomain, + ) + + def test_mailbox_without_mailbox_raises(self): + with transaction.atomic(): + with pytest.raises(IntegrityError): + _force_insert( + name="bad-empty-mailbox", + type=ChannelTypes.API_KEY, + scope_level=ChannelScopeLevel.MAILBOX, + ) + + def test_valid_global(self): + c = models.Channel.objects.create( + name="good-global", + type=ChannelTypes.API_KEY, + scope_level=ChannelScopeLevel.GLOBAL, + ) + assert c.pk is not None + + def test_valid_mailbox(self): + mailbox = MailboxFactory() + c = models.Channel.objects.create( + name="good-mailbox", + type=ChannelTypes.API_KEY, + scope_level=ChannelScopeLevel.MAILBOX, + mailbox=mailbox, + ) + assert c.pk is not None + + +# -------------------------------------------------------------------------- # +# Model clean() / full_clean +# -------------------------------------------------------------------------- # + + +@pytest.mark.django_db +class TestScopeLevelCleanValidation: + """Model-level validation: full_clean() (via validate_constraints, called + automatically from BaseModel.save) rejects every illegal scope_level/target + combination before the row reaches the DB.""" + + def test_global_with_mailbox_full_clean(self): + mailbox = MailboxFactory() + channel = models.Channel( + name="x", + type=ChannelTypes.API_KEY, + scope_level=ChannelScopeLevel.GLOBAL, + mailbox=mailbox, + ) + with pytest.raises(ValidationError): + channel.full_clean() + + def test_global_with_maildomain_full_clean(self): + maildomain = MailDomainFactory() + channel = models.Channel( + name="x", + type=ChannelTypes.API_KEY, + scope_level=ChannelScopeLevel.GLOBAL, + maildomain=maildomain, + ) + with pytest.raises(ValidationError): + channel.full_clean() + + def test_maildomain_with_mailbox_full_clean(self): + mailbox = MailboxFactory() + maildomain = MailDomainFactory() + channel = models.Channel( + name="x", + type=ChannelTypes.API_KEY, + scope_level=ChannelScopeLevel.MAILDOMAIN, + mailbox=mailbox, + maildomain=maildomain, + ) + with pytest.raises(ValidationError): + channel.full_clean() + + def test_maildomain_without_maildomain_full_clean(self): + channel = models.Channel( + name="x", + type=ChannelTypes.API_KEY, + scope_level=ChannelScopeLevel.MAILDOMAIN, + ) + with pytest.raises(ValidationError): + channel.full_clean() + + def test_mailbox_without_mailbox_full_clean(self): + channel = models.Channel( + name="x", + type=ChannelTypes.API_KEY, + scope_level=ChannelScopeLevel.MAILBOX, + ) + with pytest.raises(ValidationError): + channel.full_clean() + + def test_mailbox_with_maildomain_full_clean(self): + mailbox = MailboxFactory() + maildomain = MailDomainFactory() + channel = models.Channel( + name="x", + type=ChannelTypes.API_KEY, + scope_level=ChannelScopeLevel.MAILBOX, + mailbox=mailbox, + maildomain=maildomain, + ) + with pytest.raises(ValidationError): + channel.full_clean() + + def test_user_without_user_full_clean(self): + channel = models.Channel( + name="x", + type=ChannelTypes.API_KEY, + scope_level=ChannelScopeLevel.USER, + ) + with pytest.raises(ValidationError): + channel.full_clean() + + def test_user_with_mailbox_full_clean(self): + mailbox = MailboxFactory() + user = UserFactory() + channel = models.Channel( + name="x", + type=ChannelTypes.API_KEY, + scope_level=ChannelScopeLevel.USER, + user=user, + mailbox=mailbox, + ) + with pytest.raises(ValidationError): + channel.full_clean() + + def test_save_triggers_full_clean(self): + """BaseModel.save() runs full_clean() and rejects bad rows before the DB.""" + mailbox = MailboxFactory() + bad = models.Channel( + name="bad", + type=ChannelTypes.API_KEY, + scope_level=ChannelScopeLevel.GLOBAL, + mailbox=mailbox, + ) + with pytest.raises(ValidationError): + bad.save() + + +# -------------------------------------------------------------------------- # +# DRF path: mailbox-nested viewset never exposes or writes non-mailbox scope +# -------------------------------------------------------------------------- # + + +@pytest.mark.django_db +class TestChannelViewSetIsolation: # pylint: disable=too-many-public-methods + """The existing mailbox-nested ChannelViewSet only sees mailbox-scope rows + and only creates mailbox-scope rows — no matter what the client sends.""" + + def _url_list(self, mailbox): + return reverse("mailbox-channels-list", kwargs={"mailbox_id": mailbox.id}) + + def test_list_excludes_global(self, api_client): + user = UserFactory() + mailbox = MailboxFactory() + from core.enums import MailboxRoleChoices + + MailboxAccessFactory(mailbox=mailbox, user=user, role=MailboxRoleChoices.ADMIN) + api_client.force_authenticate(user=user) + + # Create a mailbox-scope row that should be visible... + mailbox_channel = models.Channel.objects.create( + name="mbx", + type=ChannelTypes.API_KEY, + scope_level=ChannelScopeLevel.MAILBOX, + mailbox=mailbox, + settings={"scopes": ["messages:send"]}, + ) + # ...and a global-scope row that must NOT be visible. + models.Channel.objects.create( + name="glb", + type=ChannelTypes.API_KEY, + scope_level=ChannelScopeLevel.GLOBAL, + ) + + response = api_client.get(self._url_list(mailbox)) + assert response.status_code == 200, response.content + ids = {row["id"] for row in response.data} + assert str(mailbox_channel.id) in ids + # No global channel should appear. + assert all(row.get("scope_level") == "mailbox" for row in response.data) + + def test_post_body_scope_level_is_ignored(self, api_client): + """A client sending scope_level=global in the body gets scope_level=mailbox.""" + import hashlib + import uuid + + from core.enums import MailboxRoleChoices + + user = UserFactory() + mailbox = MailboxFactory() + MailboxAccessFactory(mailbox=mailbox, user=user, role=MailboxRoleChoices.ADMIN) + api_client.force_authenticate(user=user) + + response = api_client.post( + self._url_list(mailbox), + data={ + "name": f"test-{uuid.uuid4().hex[:6]}", + "type": "api_key", + "scope_level": "global", # read-only — must be ignored + "settings": {"scopes": ["messages:send"]}, + }, + format="json", + ) + assert response.status_code == 201, response.content + created = models.Channel.objects.get(pk=response.data["id"]) + assert created.scope_level == ChannelScopeLevel.MAILBOX + assert created.mailbox_id == mailbox.id + # Response should include plaintext api_key once and the row id. + assert response.data.get("api_key", "").startswith("msgk_") + assert response.data["id"] == str(created.id) + # The returned plaintext must hash to the single stored entry in + # encrypted_settings.api_key_hashes. + expected_hash = hashlib.sha256( + response.data["api_key"].encode("utf-8") + ).hexdigest() + assert created.encrypted_settings["api_key_hashes"] == [expected_hash] + + def test_mailbox_admin_cannot_request_global_only_scope(self, api_client): + """maildomains:create requires scope_level=global and is rejected here.""" + import uuid + + from core.enums import MailboxRoleChoices + + user = UserFactory() + mailbox = MailboxFactory() + MailboxAccessFactory(mailbox=mailbox, user=user, role=MailboxRoleChoices.ADMIN) + api_client.force_authenticate(user=user) + + response = api_client.post( + self._url_list(mailbox), + data={ + "name": f"test-{uuid.uuid4().hex[:6]}", + "type": "api_key", + "settings": {"scopes": ["maildomains:create"]}, + }, + format="json", + ) + assert response.status_code == 400, response.content + + def test_mailbox_admin_cannot_request_metrics_read_scope(self, api_client): + """metrics:read is in CHANNEL_API_KEY_SCOPES_GLOBAL_ONLY too.""" + import uuid + + from core.enums import MailboxRoleChoices + + user = UserFactory() + mailbox = MailboxFactory() + MailboxAccessFactory(mailbox=mailbox, user=user, role=MailboxRoleChoices.ADMIN) + api_client.force_authenticate(user=user) + + response = api_client.post( + self._url_list(mailbox), + data={ + "name": f"test-{uuid.uuid4().hex[:6]}", + "type": "api_key", + "settings": {"scopes": ["metrics:read"]}, + }, + format="json", + ) + assert response.status_code == 400, response.content + + def test_post_body_mailbox_field_is_ignored(self, api_client): + """A client trying to bind to a different mailbox via the body is + silently overridden by the URL mailbox (mailbox is read-only).""" + import uuid + + from core.enums import MailboxRoleChoices + + user = UserFactory() + my_mailbox = MailboxFactory() + other_mailbox = MailboxFactory() + MailboxAccessFactory( + mailbox=my_mailbox, user=user, role=MailboxRoleChoices.ADMIN + ) + api_client.force_authenticate(user=user) + + response = api_client.post( + self._url_list(my_mailbox), + data={ + "name": f"test-{uuid.uuid4().hex[:6]}", + "type": "api_key", + "mailbox": str(other_mailbox.id), # read-only — ignored + "settings": {"scopes": ["messages:send"]}, + }, + format="json", + ) + assert response.status_code == 201, response.content + created = models.Channel.objects.get(pk=response.data["id"]) + assert created.mailbox_id == my_mailbox.id + assert created.scope_level == ChannelScopeLevel.MAILBOX + + def test_post_body_maildomain_field_is_ignored(self, api_client): + """Same protection for the maildomain FK in the body.""" + import uuid + + from core import factories + from core.enums import MailboxRoleChoices + + user = UserFactory() + mailbox = MailboxFactory() + unrelated_domain = factories.MailDomainFactory() + MailboxAccessFactory(mailbox=mailbox, user=user, role=MailboxRoleChoices.ADMIN) + api_client.force_authenticate(user=user) + + response = api_client.post( + self._url_list(mailbox), + data={ + "name": f"test-{uuid.uuid4().hex[:6]}", + "type": "api_key", + "maildomain": str(unrelated_domain.id), # read-only — ignored + "settings": {"scopes": ["messages:send"]}, + }, + format="json", + ) + assert response.status_code == 201, response.content + created = models.Channel.objects.get(pk=response.data["id"]) + assert created.maildomain_id is None + assert created.scope_level == ChannelScopeLevel.MAILBOX + + def test_create_response_includes_id(self, api_client): + """The create response carries the row id (= the X-Channel-Id value + on subsequent api_key calls). No separate channel_id field.""" + import uuid + + from core.enums import MailboxRoleChoices + + user = UserFactory() + mailbox = MailboxFactory() + MailboxAccessFactory(mailbox=mailbox, user=user, role=MailboxRoleChoices.ADMIN) + api_client.force_authenticate(user=user) + + response = api_client.post( + self._url_list(mailbox), + data={ + "name": f"key-{uuid.uuid4().hex[:6]}", + "type": "api_key", + "settings": {"scopes": ["messages:send"]}, + }, + format="json", + ) + assert response.status_code == 201, response.content + assert "id" in response.data + assert "channel_id" not in response.data # no duplicate field + + # ----- PATCH escalation: scope validation must run on partial updates -- # + + def _detail_url(self, mailbox, channel): + return reverse( + "mailbox-channels-detail", + kwargs={"mailbox_id": mailbox.id, "pk": channel.id}, + ) + + def _create_with_send_scope(self, api_client, mailbox): + from core.enums import MailboxRoleChoices + + admin = UserFactory() + MailboxAccessFactory(mailbox=mailbox, user=admin, role=MailboxRoleChoices.ADMIN) + api_client.force_authenticate(user=admin) + response = api_client.post( + self._url_list(mailbox), + data={ + "name": "legit", + "type": "api_key", + "settings": {"scopes": ["messages:send"]}, + }, + format="json", + ) + assert response.status_code == 201, response.content + return models.Channel.objects.get(pk=response.data["id"]) + + def test_patch_cannot_grant_global_only_scope(self, api_client): + """PATCH escalation: a mailbox admin cannot grant a global-only + scope by PATCHing settings on an existing api_key channel. + + This is the airtight test for the bug where + ``_validate_api_key_scopes`` returned early when ``type`` wasn't in + attrs (the typical PATCH shape), letting the new scopes through + unvalidated.""" + mailbox = MailboxFactory() + channel = self._create_with_send_scope(api_client, mailbox) + + response = api_client.patch( + self._detail_url(mailbox, channel), + data={"settings": {"scopes": ["maildomains:create"]}}, + format="json", + ) + assert response.status_code == 400, response.content + + # And the row was NOT mutated. + channel.refresh_from_db() + assert channel.settings["scopes"] == ["messages:send"] + + def test_patch_cannot_inject_unknown_scope(self, api_client): + """PATCH must also reject scope strings outside ChannelApiKeyScope.""" + mailbox = MailboxFactory() + channel = self._create_with_send_scope(api_client, mailbox) + + response = api_client.patch( + self._detail_url(mailbox, channel), + data={"settings": {"scopes": ["messages:send", "evil:do_anything"]}}, + format="json", + ) + assert response.status_code == 400, response.content + channel.refresh_from_db() + assert channel.settings["scopes"] == ["messages:send"] + + def test_patch_cannot_empty_scopes(self, api_client): + """PATCH replacing settings to one without scopes must fail — + api_key channels require settings.scopes.""" + mailbox = MailboxFactory() + channel = self._create_with_send_scope(api_client, mailbox) + + response = api_client.patch( + self._detail_url(mailbox, channel), + data={"settings": {"expires_at": "2030-01-01T00:00:00Z"}}, + format="json", + ) + assert response.status_code == 400, response.content + channel.refresh_from_db() + # Original scopes preserved. + assert channel.settings["scopes"] == ["messages:send"] + + def test_patch_legitimate_scope_change_works(self, api_client): + """The fix must NOT break legit narrowing PATCHes — a mailbox + admin can still PATCH from one valid mailbox-allowed scope set + to another.""" + mailbox = MailboxFactory() + channel = self._create_with_send_scope(api_client, mailbox) + + response = api_client.patch( + self._detail_url(mailbox, channel), + data={ + "settings": { + "scopes": ["messages:send", "mailboxes:read"], + } + }, + format="json", + ) + # mailboxes:read isn't in CHANNEL_API_KEY_SCOPES_GLOBAL_ONLY, so + # it's grantable on a mailbox-scope channel. + assert response.status_code == 200, response.content + channel.refresh_from_db() + assert sorted(channel.settings["scopes"]) == sorted( + ["messages:send", "mailboxes:read"] + ) + + def test_patch_rename_does_not_require_settings(self, api_client): + """A pure rename PATCH must NOT trip the api_key validators.""" + mailbox = MailboxFactory() + channel = self._create_with_send_scope(api_client, mailbox) + + response = api_client.patch( + self._detail_url(mailbox, channel), + data={"name": "renamed"}, + format="json", + ) + assert response.status_code == 200, response.content + channel.refresh_from_db() + assert channel.name == "renamed" + assert channel.settings["scopes"] == ["messages:send"] + + # ----- Regression locks: read-only fields stay read-only on PATCH ----- # + # + # These tests assert behaviors that are CURRENTLY airtight (read-only + # fields, the constraint, etc.). They exist so that any future change + # that accidentally makes one of these writable trips a failing test. + # Do NOT delete these as "redundant" — they are the regression net. + + def test_patch_scope_level_in_body_is_ignored(self, api_client): + """PATCH cannot escalate scope_level via the body.""" + mailbox = MailboxFactory() + channel = self._create_with_send_scope(api_client, mailbox) + + response = api_client.patch( + self._detail_url(mailbox, channel), + data={"scope_level": "global"}, + format="json", + ) + assert response.status_code == 200, response.content + channel.refresh_from_db() + assert channel.scope_level == ChannelScopeLevel.MAILBOX + + def test_patch_mailbox_in_body_is_ignored(self, api_client): + """PATCH cannot rebind a channel to a different mailbox.""" + mailbox = MailboxFactory() + other_mailbox = MailboxFactory() + channel = self._create_with_send_scope(api_client, mailbox) + + response = api_client.patch( + self._detail_url(mailbox, channel), + data={"mailbox": str(other_mailbox.id)}, + format="json", + ) + assert response.status_code == 200, response.content + channel.refresh_from_db() + assert channel.mailbox_id == mailbox.id + + def test_patch_maildomain_in_body_is_ignored(self, api_client): + """PATCH cannot rebind a channel to a different maildomain.""" + from core import factories + + mailbox = MailboxFactory() + unrelated_domain = factories.MailDomainFactory() + channel = self._create_with_send_scope(api_client, mailbox) + + response = api_client.patch( + self._detail_url(mailbox, channel), + data={"maildomain": str(unrelated_domain.id)}, + format="json", + ) + assert response.status_code == 200, response.content + channel.refresh_from_db() + assert channel.maildomain_id is None + + def test_patch_user_in_body_is_ignored(self, api_client): + """PATCH cannot rebind the creator/target user via the body.""" + mailbox = MailboxFactory() + channel = self._create_with_send_scope(api_client, mailbox) + original_user_id = channel.user_id + attacker = UserFactory() + + response = api_client.patch( + self._detail_url(mailbox, channel), + data={"user": str(attacker.id)}, + format="json", + ) + assert response.status_code == 200, response.content + channel.refresh_from_db() + assert channel.user_id == original_user_id + + def test_create_encrypted_settings_in_body_is_ignored(self, api_client): + """``encrypted_settings`` is not in ChannelSerializer.fields, so the + whole top-level field is silently dropped — the row's + encrypted_settings is what the server generated, never what the + caller sent.""" + from core.enums import MailboxRoleChoices + + user = UserFactory() + mailbox = MailboxFactory() + MailboxAccessFactory(mailbox=mailbox, user=user, role=MailboxRoleChoices.ADMIN) + api_client.force_authenticate(user=user) + + response = api_client.post( + self._url_list(mailbox), + data={ + "name": "ess", + "type": "api_key", + "settings": {"scopes": ["messages:send"]}, + "encrypted_settings": {"api_key_hashes": ["evil" * 16]}, + }, + format="json", + ) + assert response.status_code == 201, response.content + created = models.Channel.objects.get(pk=response.data["id"]) + # The server-generated hash is what landed; the caller's "evil" + # injection was dropped because encrypted_settings isn't a field. + assert created.encrypted_settings["api_key_hashes"] != ["evil" * 16] + + def test_patch_encrypted_settings_in_body_is_ignored(self, api_client): + """Same protection on PATCH.""" + mailbox = MailboxFactory() + channel = self._create_with_send_scope(api_client, mailbox) + original_hashes = list(channel.encrypted_settings["api_key_hashes"]) + + response = api_client.patch( + self._detail_url(mailbox, channel), + data={"encrypted_settings": {"api_key_hashes": ["evil" * 16]}}, + format="json", + ) + assert response.status_code == 200, response.content + channel.refresh_from_db() + assert channel.encrypted_settings["api_key_hashes"] == original_hashes + + # ----- Real holes that need fixing -------------------------------------- # + + def test_patch_settings_with_api_key_hashes_is_rejected(self, api_client): + """A mailbox admin must NOT be able to inject their own api_key_hash + by smuggling it into ``settings``. The serializer's + _move_sensitive_settings hook would otherwise extract it from + settings and write it into encrypted_settings, letting the attacker + choose the hash that authenticates the channel.""" + import hashlib + + mailbox = MailboxFactory() + channel = self._create_with_send_scope(api_client, mailbox) + legit_hashes = list(channel.encrypted_settings["api_key_hashes"]) + evil_plaintext = "evil_known_secret" + evil_hash = hashlib.sha256(evil_plaintext.encode()).hexdigest() + + response = api_client.patch( + self._detail_url(mailbox, channel), + data={ + "settings": { + "scopes": ["messages:send"], + "api_key_hashes": [evil_hash], + } + }, + format="json", + ) + assert response.status_code == 400, response.content + channel.refresh_from_db() + # The attacker's hash MUST NOT have been written to encrypted_settings. + assert channel.encrypted_settings["api_key_hashes"] == legit_hashes + + def test_create_settings_with_api_key_hashes_is_rejected(self, api_client): + """Same defense on CREATE — even though _generate_api_key_material + currently overwrites the hash, the request itself should be + rejected so the caller's intent is loud, not silently dropped.""" + from core.enums import MailboxRoleChoices + + user = UserFactory() + mailbox = MailboxFactory() + MailboxAccessFactory(mailbox=mailbox, user=user, role=MailboxRoleChoices.ADMIN) + api_client.force_authenticate(user=user) + + response = api_client.post( + self._url_list(mailbox), + data={ + "name": "evil-create", + "type": "api_key", + "settings": { + "scopes": ["messages:send"], + "api_key_hashes": ["a" * 64], + }, + }, + format="json", + ) + assert response.status_code == 400, response.content + + @override_settings(FEATURE_MAILBOX_ADMIN_CHANNELS=["api_key", "widget"]) + def test_patch_type_is_immutable(self, api_client): + """The ``type`` of an existing channel is immutable from DRF. + + Allowing PATCH ``type=widget → api_key`` would let a mailbox admin + sneak around the create-time scope checks; allowing the reverse + would let them strand a row whose type the auth class no longer + recognizes. We override FEATURE_MAILBOX_ADMIN_CHANNELS to include + BOTH types so this test exercises type-mutability itself, not the + (separate) feature-flag allowlist check. + + DRF's standard behavior for read-only fields is silent drop on + write — same as the other read-only FKs (mailbox, scope_level, + etc.). The PATCH succeeds with 200 but the type is unchanged. + """ + mailbox = MailboxFactory() + channel = self._create_with_send_scope(api_client, mailbox) + assert channel.type == "api_key" + + response = api_client.patch( + self._detail_url(mailbox, channel), + data={"type": "widget"}, + format="json", + ) + assert response.status_code == 200, response.content + channel.refresh_from_db() + assert channel.type == "api_key" + + def test_patch_disallowed_type_in_body_is_silently_dropped(self, api_client): + """A PATCH with a type that isn't even in the allowlist still has + no effect — type is read-only on update, so DRF drops the field + silently regardless of whether the value would have been allowed.""" + mailbox = MailboxFactory() + channel = self._create_with_send_scope(api_client, mailbox) + + response = api_client.patch( + self._detail_url(mailbox, channel), + data={"type": "mta"}, # not in FEATURE_MAILBOX_ADMIN_CHANNELS + format="json", + ) + assert response.status_code == 200, response.content + channel.refresh_from_db() + assert channel.type == "api_key" + + +@pytest.mark.django_db +class TestUserChannelViewSet: + """The /users/me/channels/ viewset for personal scope_level=user channels.""" + + URL = "/api/v1.0/users/me/channels/" + + def test_unauthenticated_returns_401(self, api_client): + response = api_client.post( + self.URL, + data={ + "name": "personal", + "type": "api_key", + "settings": {"scopes": ["messages:send"]}, + }, + format="json", + ) + assert response.status_code in (401, 403) + + def test_create_personal_api_key(self, api_client): + from core.enums import MailboxRoleChoices + + user = UserFactory() + mailbox = MailboxFactory() + MailboxAccessFactory(mailbox=mailbox, user=user, role=MailboxRoleChoices.SENDER) + api_client.force_authenticate(user=user) + + response = api_client.post( + self.URL, + data={ + "name": "my-key", + "type": "api_key", + "settings": {"scopes": ["messages:send"]}, + }, + format="json", + ) + assert response.status_code == 201, response.content + assert "id" in response.data + assert response.data.get("api_key", "").startswith("msgk_") + + created = models.Channel.objects.get(pk=response.data["id"]) + assert created.scope_level == ChannelScopeLevel.USER + assert created.user_id == user.id + assert created.mailbox_id is None + assert created.maildomain_id is None + + def test_personal_key_only_lists_own_channels(self, api_client): + alice = UserFactory() + bob = UserFactory() + # Create one personal channel for each via the model directly + models.Channel.objects.create( + name="alice-key", + type="api_key", + scope_level=ChannelScopeLevel.USER, + user=alice, + settings={"scopes": ["messages:send"]}, + ) + models.Channel.objects.create( + name="bob-key", + type="api_key", + scope_level=ChannelScopeLevel.USER, + user=bob, + settings={"scopes": ["messages:send"]}, + ) + + api_client.force_authenticate(user=alice) + response = api_client.get(self.URL) + assert response.status_code == 200 + names = {row["name"] for row in response.data} + assert names == {"alice-key"} + + def test_personal_key_cannot_request_global_only_scope(self, api_client): + user = UserFactory() + api_client.force_authenticate(user=user) + response = api_client.post( + self.URL, + data={ + "name": "no-globals", + "type": "api_key", + "settings": {"scopes": ["maildomains:create"]}, + }, + format="json", + ) + assert response.status_code == 400, response.content + + def test_personal_key_covers_user_mailboxes(self, db): # pylint: disable=unused-argument + """A user-scope channel's api_key_covers respects MailboxAccess.""" + from core.enums import MailboxRoleChoices + + user = UserFactory() + accessible_mailbox = MailboxFactory() + unrelated_mailbox = MailboxFactory() + MailboxAccessFactory( + mailbox=accessible_mailbox, + user=user, + role=MailboxRoleChoices.SENDER, + ) + + channel = models.Channel.objects.create( + name="personal", + type="api_key", + scope_level=ChannelScopeLevel.USER, + user=user, + settings={"scopes": ["messages:send"]}, + ) + + assert channel.api_key_covers(mailbox=accessible_mailbox) is True + assert channel.api_key_covers(mailbox=unrelated_mailbox) is False + + def test_personal_key_role_check_rejects_viewer(self, db): # pylint: disable=unused-argument + """A viewer-only user-scope key must NOT pass api_key_covers when + the endpoint requires a SENDER-or-better role. This is the regression + test for the viewer-can-submit hole.""" + from core.enums import MAILBOX_ROLES_CAN_SEND, MailboxRoleChoices + + user = UserFactory() + mailbox = MailboxFactory() + MailboxAccessFactory(mailbox=mailbox, user=user, role=MailboxRoleChoices.VIEWER) + + channel = models.Channel.objects.create( + name="viewer-personal", + type="api_key", + scope_level=ChannelScopeLevel.USER, + user=user, + settings={"scopes": ["messages:send"]}, + ) + + # Without role kwarg the helper still says True (any access exists). + assert channel.api_key_covers(mailbox=mailbox) is True + # With the SENDER-or-better requirement it must say False. + assert ( + channel.api_key_covers( + mailbox=mailbox, mailbox_roles=MAILBOX_ROLES_CAN_SEND + ) + is False + ) + + # ----------- cross-user isolation: retrieve / update / destroy -------- # + + def _make_personal(self, user, name="key"): + return models.Channel.objects.create( + name=name, + type="api_key", + scope_level=ChannelScopeLevel.USER, + user=user, + settings={"scopes": ["messages:send"]}, + encrypted_settings={"api_key_hashes": ["x" * 64]}, + ) + + def _detail_url(self, channel): + return f"{self.URL}{channel.id}/" + + def test_retrieve_other_users_channel_returns_404(self, api_client): + alice = UserFactory() + bob = UserFactory() + bob_channel = self._make_personal(bob) + + api_client.force_authenticate(user=alice) + response = api_client.get(self._detail_url(bob_channel)) + assert response.status_code == 404 + + def test_update_other_users_channel_returns_404(self, api_client): + alice = UserFactory() + bob = UserFactory() + bob_channel = self._make_personal(bob) + + api_client.force_authenticate(user=alice) + response = api_client.patch( + self._detail_url(bob_channel), + data={"name": "stolen"}, + format="json", + ) + assert response.status_code == 404 + bob_channel.refresh_from_db() + assert bob_channel.name == "key" # untouched + + def test_destroy_other_users_channel_returns_404(self, api_client): + alice = UserFactory() + bob = UserFactory() + bob_channel = self._make_personal(bob) + + api_client.force_authenticate(user=alice) + response = api_client.delete(self._detail_url(bob_channel)) + assert response.status_code == 404 + assert models.Channel.objects.filter(pk=bob_channel.pk).exists() + + def test_owner_can_destroy_own_channel(self, api_client): + user = UserFactory() + channel = self._make_personal(user) + + api_client.force_authenticate(user=user) + response = api_client.delete(self._detail_url(channel)) + assert response.status_code == 204 + assert not models.Channel.objects.filter(pk=channel.pk).exists() + + def test_owner_can_rename_own_channel(self, api_client): + user = UserFactory() + channel = self._make_personal(user) + + api_client.force_authenticate(user=user) + response = api_client.patch( + self._detail_url(channel), + data={"name": "renamed"}, + format="json", + ) + assert response.status_code == 200 + channel.refresh_from_db() + assert channel.name == "renamed" + + def test_user_creating_multiple_personal_channels(self, api_client): + """A user can hold several personal api_keys at the same time.""" + user = UserFactory() + api_client.force_authenticate(user=user) + + ids = set() + for i in range(3): + response = api_client.post( + self.URL, + data={ + "name": f"key-{i}", + "type": "api_key", + "settings": {"scopes": ["messages:send"]}, + }, + format="json", + ) + assert response.status_code == 201, response.content + ids.add(response.data["id"]) + assert len(ids) == 3 + assert ( + models.Channel.objects.filter( + user=user, scope_level=ChannelScopeLevel.USER + ).count() + == 3 + ) + + def test_user_in_post_body_is_ignored(self, api_client): + """Trying to bind a personal channel to another user via the body + is silently overridden by the request.user.""" + alice = UserFactory() + bob = UserFactory() + api_client.force_authenticate(user=alice) + + response = api_client.post( + self.URL, + data={ + "name": "stolen-target", + "type": "api_key", + "user": str(bob.id), # read-only — ignored + "settings": {"scopes": ["messages:send"]}, + }, + format="json", + ) + assert response.status_code == 201, response.content + created = models.Channel.objects.get(pk=response.data["id"]) + assert created.user_id == alice.id + + @override_settings(FEATURE_MAILBOX_ADMIN_CHANNELS=["api_key", "webhook"]) + def test_personal_webhook_channel(self, api_client): + """Webhooks ARE creatable as personal channels — once the type is + enabled in FEATURE_MAILBOX_ADMIN_CHANNELS. The type is intentionally + left out of the production default until the outbound webhook + delivery pipeline lands; this test enables it locally so the + model + serializer + url/events validation path stays covered.""" + user = UserFactory() + api_client.force_authenticate(user=user) + + response = api_client.post( + self.URL, + data={ + "name": "personal-webhook", + "type": "webhook", + "settings": { + "url": "https://hook.example.com/me", + "events": ["message.received"], + }, + }, + format="json", + ) + assert response.status_code == 201, response.content + # No HMAC secret generation yet — that scaffolding lands with the + # delivery pipeline. The response carries the row id only. + assert "hmac_secret" not in response.data + created = models.Channel.objects.get(pk=response.data["id"]) + assert created.scope_level == ChannelScopeLevel.USER + assert created.user_id == user.id + + +@pytest.mark.django_db +class TestUserDeleteCascade: + """Deleting a User must remove their personal scope_level=user channels + and *only* those — never blanket-cascade via the FK.""" + + def test_user_delete_removes_only_user_scope_channels(self): + user = UserFactory() + mailbox = MailboxFactory() + + # The user owns one personal channel. + personal = models.Channel.objects.create( + name="personal", + type="api_key", + scope_level=ChannelScopeLevel.USER, + user=user, + settings={"scopes": ["messages:send"]}, + ) + # And there's an unrelated mailbox-scope channel that should + # survive the user's deletion. + mailbox_channel = models.Channel.objects.create( + name="mbx", + type="api_key", + scope_level=ChannelScopeLevel.MAILBOX, + mailbox=mailbox, + settings={"scopes": ["messages:send"]}, + ) + + user.delete() + + assert not models.Channel.objects.filter(pk=personal.pk).exists() + assert models.Channel.objects.filter(pk=mailbox_channel.pk).exists() + + def test_user_delete_with_no_personal_channels_succeeds(self): + """Deleting a user without any personal channels works even though + the pre_delete handler has nothing to clean up.""" + user = UserFactory() + user.delete() + # Reaching this line is the assertion. + + +@pytest.mark.django_db +class TestRegenerateApiKey: + """The regenerate-api-key action: single-active replace, never append. + + DRF's only rotation flow. Smooth (dual-active) rotation would happen + in the Django admin or a future CLI command. + """ + + SUBMIT_URL = "/api/v1.0/submit/" + + @staticmethod + def _hash(plaintext): + import hashlib + + return hashlib.sha256(plaintext.encode("utf-8")).hexdigest() + + # ---- mailbox-nested viewset ----------------------------------------- # + + def _mailbox_url(self, mailbox, channel): + return reverse( + "mailbox-channels-regenerate-api-key", + kwargs={"mailbox_id": mailbox.id, "pk": channel.id}, + ) + + def _create_mailbox_api_key(self, api_client, mailbox): + from core.enums import MailboxRoleChoices + + admin = UserFactory() + MailboxAccessFactory(mailbox=mailbox, user=admin, role=MailboxRoleChoices.ADMIN) + api_client.force_authenticate(user=admin) + response = api_client.post( + reverse("mailbox-channels-list", kwargs={"mailbox_id": mailbox.id}), + data={ + "name": "rotatable", + "type": "api_key", + "settings": {"scopes": ["messages:send"]}, + }, + format="json", + ) + assert response.status_code == 201, response.content + return admin, response.data["id"], response.data["api_key"] + + def test_regenerate_replaces_secret(self, api_client): + mailbox = MailboxFactory() + _admin, channel_id, original_plaintext = self._create_mailbox_api_key( + api_client, mailbox + ) + original_hashes = list( + models.Channel.objects.get(pk=channel_id).encrypted_settings[ + "api_key_hashes" + ] + ) + assert original_hashes == [self._hash(original_plaintext)] + + response = api_client.post( + self._mailbox_url(mailbox, models.Channel.objects.get(pk=channel_id)) + ) + assert response.status_code == 200, response.content + new_plaintext = response.data["api_key"] + assert new_plaintext.startswith("msgk_") + assert new_plaintext != original_plaintext + assert response.data["id"] == str(channel_id) + + stored = models.Channel.objects.get(pk=channel_id).encrypted_settings[ + "api_key_hashes" + ] + # Single-active: exactly one hash, the new one. The old one is gone. + assert len(stored) == 1 + assert stored == [self._hash(new_plaintext)] + + def test_regenerate_invalidates_old_secret_end_to_end(self, api_client, client): + """After regenerate, the old plaintext immediately fails auth on + a real endpoint.""" + from core.enums import MailboxRoleChoices + + mailbox = MailboxFactory() + admin = UserFactory() + MailboxAccessFactory(mailbox=mailbox, user=admin, role=MailboxRoleChoices.ADMIN) + api_client.force_authenticate(user=admin) + # Create a key with messages:send scope so we can hit /submit/. + create_url = reverse("mailbox-channels-list", kwargs={"mailbox_id": mailbox.id}) + response = api_client.post( + create_url, + data={ + "name": "soon-rotated", + "type": "api_key", + "settings": {"scopes": ["messages:send"]}, + }, + format="json", + ) + channel_id = response.data["id"] + old_plaintext = response.data["api_key"] + + # Regenerate. + response = api_client.post( + self._mailbox_url(mailbox, models.Channel.objects.get(pk=channel_id)) + ) + assert response.status_code == 200 + new_plaintext = response.data["api_key"] + + # Old plaintext → /submit/ rejects with 401 (auth-layer failure). + # We use the unauthenticated `client` to avoid the OIDC session + # taking precedence over the api_key auth class. + response = client.post( + self.SUBMIT_URL, + data=b"", + content_type="message/rfc822", + HTTP_X_CHANNEL_ID=str(channel_id), + HTTP_X_API_KEY=old_plaintext, + HTTP_X_MAIL_FROM=str(mailbox.id), + HTTP_X_RCPT_TO="x@example.com", + ) + assert response.status_code == 401 + + # New plaintext is accepted by the auth layer (the body is empty + # so the view returns 400, but auth has already passed). + response = client.post( + self.SUBMIT_URL, + data=b"", + content_type="message/rfc822", + HTTP_X_CHANNEL_ID=str(channel_id), + HTTP_X_API_KEY=new_plaintext, + HTTP_X_MAIL_FROM=str(mailbox.id), + HTTP_X_RCPT_TO="x@example.com", + ) + assert response.status_code == 400 # empty body, but auth passed + + def test_regenerate_rejected_on_non_api_key_type(self, api_client): + from core.enums import MailboxRoleChoices + + mailbox = MailboxFactory() + admin = UserFactory() + MailboxAccessFactory(mailbox=mailbox, user=admin, role=MailboxRoleChoices.ADMIN) + api_client.force_authenticate(user=admin) + + widget = models.Channel.objects.create( + name="widget", + type="widget", + scope_level=ChannelScopeLevel.MAILBOX, + mailbox=mailbox, + settings={"config": {"enabled": True}}, + ) + response = api_client.post(self._mailbox_url(mailbox, widget)) + assert response.status_code == 400, response.content + + def test_regenerate_unauthenticated_returns_403(self, api_client): + mailbox = MailboxFactory() + channel = models.Channel.objects.create( + name="x", + type="api_key", + scope_level=ChannelScopeLevel.MAILBOX, + mailbox=mailbox, + settings={"scopes": ["messages:send"]}, + encrypted_settings={"api_key_hashes": ["x" * 64]}, + ) + response = api_client.post(self._mailbox_url(mailbox, channel)) + assert response.status_code in (401, 403) + + def test_regenerate_other_mailbox_admin_returns_403(self, api_client): + from core.enums import MailboxRoleChoices + + mailbox_a = MailboxFactory() + mailbox_b = MailboxFactory() + admin_b = UserFactory() + MailboxAccessFactory( + mailbox=mailbox_b, user=admin_b, role=MailboxRoleChoices.ADMIN + ) + api_client.force_authenticate(user=admin_b) + + channel_in_a = models.Channel.objects.create( + name="not-yours", + type="api_key", + scope_level=ChannelScopeLevel.MAILBOX, + mailbox=mailbox_a, + settings={"scopes": ["messages:send"]}, + encrypted_settings={"api_key_hashes": ["x" * 64]}, + ) + # Use mailbox_a's URL but auth as admin_b → IsMailboxAdmin denies. + response = api_client.post(self._mailbox_url(mailbox_a, channel_in_a)) + assert response.status_code in (403, 404) + + # ---- /users/me/channels/ -------------------------------------------- # + + def _user_url(self, channel): + return reverse("user-channels-regenerate-api-key", kwargs={"pk": channel.id}) + + def test_regenerate_personal_api_key(self, api_client): + user = UserFactory() + api_client.force_authenticate(user=user) + # Mint a personal api_key first. + response = api_client.post( + "/api/v1.0/users/me/channels/", + data={ + "name": "personal-rotatable", + "type": "api_key", + "settings": {"scopes": ["messages:send"]}, + }, + format="json", + ) + assert response.status_code == 201 + channel_id = response.data["id"] + original_plaintext = response.data["api_key"] + + response = api_client.post( + self._user_url(models.Channel.objects.get(pk=channel_id)) + ) + assert response.status_code == 200, response.content + new_plaintext = response.data["api_key"] + assert new_plaintext != original_plaintext + assert models.Channel.objects.get(pk=channel_id).encrypted_settings[ + "api_key_hashes" + ] == [self._hash(new_plaintext)] + + def test_regenerate_other_users_personal_key_returns_404(self, api_client): + alice = UserFactory() + bob = UserFactory() + bob_channel = models.Channel.objects.create( + name="bob-personal", + type="api_key", + scope_level=ChannelScopeLevel.USER, + user=bob, + settings={"scopes": ["messages:send"]}, + encrypted_settings={"api_key_hashes": ["x" * 64]}, + ) + api_client.force_authenticate(user=alice) + response = api_client.post(self._user_url(bob_channel)) + assert response.status_code == 404 + # Bob's secret was untouched. + assert models.Channel.objects.get(pk=bob_channel.pk).encrypted_settings[ + "api_key_hashes" + ] == ["x" * 64] diff --git a/src/backend/core/tests/api/test_channels.py b/src/backend/core/tests/api/test_channels.py index 6c3bb4cb..ee7a21c9 100644 --- a/src/backend/core/tests/api/test_channels.py +++ b/src/backend/core/tests/api/test_channels.py @@ -1,6 +1,6 @@ """Tests for the channel API endpoints.""" -# pylint: disable=redefined-outer-name, unused-argument, too-many-public-methods +# pylint: disable=redefined-outer-name, unused-argument, too-many-public-methods, import-outside-toplevel import uuid @@ -252,6 +252,20 @@ class TestChannelCreate: assert response.status_code == status.HTTP_201_CREATED assert response.data["type"] == "widget" + @override_settings(FEATURE_MAILBOX_ADMIN_CHANNELS=["widget"]) + def test_create_channel_missing_type_is_rejected(self, api_client, mailbox): + """Omitting ``type`` on CREATE must be a 400 — never silently default + to "mta" and bypass FEATURE_MAILBOX_ADMIN_CHANNELS. Regression lock + for the bug where the model field default and the serializer's + ``if channel_type:`` short-circuit combined to let "mta" channels + through on any nested mailbox/user POST.""" + url = reverse("mailbox-channels-list", kwargs={"mailbox_id": mailbox.id}) + response = api_client.post( + url, {"name": "no type", "settings": {}}, format="json" + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "type" in response.data + @pytest.mark.django_db class TestChannelRetrieve: @@ -421,3 +435,134 @@ class TestChannelDomainAdminAccess: assert response.status_code == status.HTTP_201_CREATED assert response.data["name"] == "Domain Admin Widget" + + +@pytest.mark.django_db +class TestChannelEncryptedSettings: + """Test encrypted_settings and user fields on the Channel model.""" + + def test_encrypted_settings_stored_on_model(self, mailbox): + """encrypted_settings can be set and read back.""" + channel = ChannelFactory( + mailbox=mailbox, type="widget", settings={"public": "value"} + ) + channel.encrypted_settings = {"secret_key": "s3cret"} + channel.save() + + channel.refresh_from_db() + assert channel.encrypted_settings["secret_key"] == "s3cret" + assert channel.settings["public"] == "value" + + def test_encrypted_settings_not_in_api_response(self, api_client, mailbox): + """encrypted_settings must never leak in the REST API — neither as + a top-level key nor smuggled into the visible ``settings`` payload.""" + channel = ChannelFactory(mailbox=mailbox, type="widget") + channel.encrypted_settings = {"password": "s3cret"} + channel.save() + + url = reverse( + "mailbox-channels-detail", + kwargs={"mailbox_id": mailbox.id, "pk": channel.id}, + ) + response = api_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert "encrypted_settings" not in response.data + assert "password" not in response.data + + # Defense in depth: a serializer bug that copied encrypted_settings + # into the visible ``settings`` JSON would also be a leak. Inspect + # both the secret keys and the secret values. + settings_payload = response.data.get("settings") or {} + assert "password" not in settings_payload + assert "s3cret" not in settings_payload.values() + + def test_encrypted_settings_not_in_list_response(self, api_client, mailbox): + """encrypted_settings must not appear in list responses either — + same defense-in-depth check on each item's ``settings`` payload.""" + channel = ChannelFactory(mailbox=mailbox, type="widget") + channel.encrypted_settings = {"token": "abc"} + channel.save() + + url = reverse("mailbox-channels-list", kwargs={"mailbox_id": mailbox.id}) + response = api_client.get(url) + + assert response.status_code == status.HTTP_200_OK + for item in response.data: + assert "encrypted_settings" not in item + settings_payload = item.get("settings") or {} + assert "token" not in settings_payload + assert "abc" not in settings_payload.values() + + def test_user_field_target_on_user_scope_channel(self, user): + """Channel.user is the *target* user for scope_level=user channels.""" + channel = ChannelFactory( + user=user, scope_level="user", mailbox=None, maildomain=None + ) + channel.refresh_from_db() + assert channel.user == user + + def test_user_field_creator_audit_on_mailbox_scope_channel(self, user, mailbox): + """On non-user-scope channels, Channel.user is the creator audit + — an OPTIONAL FK pointing at the User who created it via DRF.""" + channel = ChannelFactory(mailbox=mailbox, type="widget", user=user) + channel.refresh_from_db() + assert channel.user == user + + def test_user_field_nullable_on_mailbox_scope_channel(self, mailbox): + """The creator FK is nullable — channels created by the CLI / data + migration / Django admin may not have a creator stamped.""" + channel = ChannelFactory(mailbox=mailbox, type="widget") + assert channel.user is None + + +@pytest.mark.django_db +class TestChannelReservedSettingsKeys: + """The serializer rejects callers that try to write reserved settings + keys (e.g. ``api_key_hashes``). Server-side generators write directly + to encrypted_settings, callers cannot influence its contents.""" + + @override_settings(FEATURE_MAILBOX_ADMIN_CHANNELS=["api_key"]) + def test_post_with_reserved_key_in_settings_is_rejected(self, api_client, mailbox): + """Smuggling api_key_hashes via settings is a 400.""" + url = reverse("mailbox-channels-list", kwargs={"mailbox_id": mailbox.id}) + response = api_client.post( + url, + data={ + "name": "Tries to inject a hash", + "type": "api_key", + "settings": { + "scopes": ["messages:send"], + "api_key_hashes": ["a" * 64], + }, + }, + format="json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @override_settings(FEATURE_MAILBOX_ADMIN_CHANNELS=["api_key"]) + def test_unrelated_settings_keys_pass_through(self, api_client, mailbox): + """Non-reserved keys in settings (e.g. expires_at) flow through + the API as caller-supplied data — only the reserved list is locked + down.""" + url = reverse("mailbox-channels-list", kwargs={"mailbox_id": mailbox.id}) + response = api_client.post( + url, + data={ + "name": "Has expires_at", + "type": "api_key", + "settings": { + "scopes": ["messages:send"], + "expires_at": "2030-01-01T00:00:00Z", + }, + }, + format="json", + ) + assert response.status_code == status.HTTP_201_CREATED + channel = models.Channel.objects.get(id=response.data["id"]) + assert channel.settings["expires_at"] == "2030-01-01T00:00:00Z" + # And api_key_hashes is what the server generated, not what the + # caller smuggled (the caller didn't smuggle anything here, but + # we still assert the encrypted_settings shape). + assert "api_key_hashes" in channel.encrypted_settings + assert "api_key_hashes" not in channel.settings diff --git a/src/backend/core/tests/api/test_inbound_widget.py b/src/backend/core/tests/api/test_inbound_widget.py index f3b90f1d..125add60 100644 --- a/src/backend/core/tests/api/test_inbound_widget.py +++ b/src/backend/core/tests/api/test_inbound_widget.py @@ -58,18 +58,20 @@ def fixture_channel_without_mailbox(): @pytest.mark.django_db def test_inbound_widget_channel_model(): - """Test the Channel model.""" + """Test the Channel model scope_level invariants. + + - mailbox AND maildomain both set → rejected (scope_level must pick one). + - neither set → valid global channel (scope_level=global). + """ with pytest.raises(ValidationError): factories.ChannelFactory( mailbox=factories.MailboxFactory(), maildomain=factories.MailDomainFactory(), ) - with pytest.raises(ValidationError): - factories.ChannelFactory( - mailbox=None, - maildomain=None, - ) + # A channel with neither mailbox nor maildomain is now a valid global channel. + global_channel = factories.ChannelFactory(mailbox=None, maildomain=None) + assert global_channel.scope_level == "global" @pytest.mark.django_db diff --git a/src/backend/core/tests/api/test_mailbox_usage_metrics.py b/src/backend/core/tests/api/test_mailbox_usage_metrics.py index 213706c1..ec3fed40 100644 --- a/src/backend/core/tests/api/test_mailbox_usage_metrics.py +++ b/src/backend/core/tests/api/test_mailbox_usage_metrics.py @@ -1,11 +1,14 @@ """Tests for the mailbox usage metrics endpoint.""" -# pylint: disable=redefined-outer-name, too-many-public-methods +# pylint: disable=redefined-outer-name, too-many-public-methods, too-many-lines, unused-argument from django.urls import reverse import pytest -from core.enums import MessageTemplateTypeChoices +from core.enums import ( + ChannelApiKeyScope, + MessageTemplateTypeChoices, +) from core.factories import ( AttachmentFactory, BlobFactory, @@ -16,6 +19,7 @@ from core.factories import ( MessageTemplateFactory, ThreadAccessFactory, ThreadFactory, + make_api_key_channel, ) @@ -26,9 +30,16 @@ def url(): @pytest.fixture -def correctly_configured_header(settings): - """Returns the authentication header for the metrics endpoint.""" - return {"HTTP_AUTHORIZATION": f"Bearer {settings.METRICS_API_KEY}"} +def correctly_configured_header(db): + """Returns the authentication headers via a global api_key Channel.""" + channel, plaintext = make_api_key_channel( + scopes=(ChannelApiKeyScope.METRICS_READ.value,), + name="metrics-test", + ) + return { + "HTTP_X_CHANNEL_ID": str(channel.id), + "HTTP_X_API_KEY": plaintext, + } CUSTOM_ATTRIBUTES_SCHEMA = { @@ -52,14 +63,23 @@ class TestMailboxUsageMetrics: @pytest.mark.django_db def test_requires_auth(self, api_client, url, correctly_configured_header): - """Requires valid API key for access.""" + """Requires a valid api_key Channel to access. + + No headers → 401 (NotAuthenticated). Invalid → 401. Valid → 200. + """ # Without authentication response = api_client.get(url) - assert response.status_code == 403 + assert response.status_code == 401 - # Invalid authentication - response = api_client.get(url, HTTP_AUTHORIZATION="Bearer invalid_token") - assert response.status_code == 403 + # Invalid authentication: reuse the real channel id with a wrong + # secret so the hash-mismatch branch (not just Channel.DoesNotExist) + # is exercised. + response = api_client.get( + url, + HTTP_X_CHANNEL_ID=correctly_configured_header["HTTP_X_CHANNEL_ID"], + HTTP_X_API_KEY="invalid_token", + ) + assert response.status_code == 401 # Valid authentication response = api_client.get(url, **correctly_configured_header) diff --git a/src/backend/core/tests/api/test_maildomain_users_metrics.py b/src/backend/core/tests/api/test_maildomain_users_metrics.py index 5689c986..2e83d533 100644 --- a/src/backend/core/tests/api/test_maildomain_users_metrics.py +++ b/src/backend/core/tests/api/test_maildomain_users_metrics.py @@ -1,12 +1,17 @@ """Tests for the Maildomain users metrics endpoint.""" # pylint: disable=redefined-outer-name, unused-argument, too-many-lines +import uuid + from django.urls import reverse from django.utils import timezone import pytest -from core.enums import MessageTemplateTypeChoices +from core.enums import ( + ChannelApiKeyScope, + MessageTemplateTypeChoices, +) from core.factories import ( AttachmentFactory, BlobFactory, @@ -19,10 +24,19 @@ from core.factories import ( ThreadAccessFactory, ThreadFactory, UserFactory, + make_api_key_channel, ) from core.models import MailboxAccess, MailDomain +def _make_metrics_api_key(): + """Create a global-scope api_key channel with metrics:read.""" + return make_api_key_channel( + scopes=(ChannelApiKeyScope.METRICS_READ.value,), + name="metrics-test", + ) + + def check_results_for_key( results: dict | list, expected: dict[str, int], @@ -69,11 +83,16 @@ def url_with_siret_query_param(url): @pytest.fixture -def correctly_configured_header(settings): +def correctly_configured_header(db): """ - Returns the authentication header for the metrics endpoint. + Returns the authentication headers for the metrics endpoint via a + scope_level=global api_key Channel with metrics:read. """ - return {"HTTP_AUTHORIZATION": f"Bearer {settings.METRICS_API_KEY}"} + channel, plaintext = _make_metrics_api_key() + return { + "HTTP_X_CHANNEL_ID": str(channel.id), + "HTTP_X_API_KEY": plaintext, + } def grant_access_to_mailbox_accessed_at(mailbox, user, accessed_at: timezone = None): @@ -131,18 +150,22 @@ class TestMailDomainUsersMetrics: self, api_client, url, correctly_configured_header ): """ - Requires valid API key for access. + Requires a valid api_key Channel to access. - Asserts that requests without or with invalid authentication are rejected (403), - and requests with the correct API key are accepted (200). + No headers → 401 (NotAuthenticated). Invalid credentials → 401 + (AuthenticationFailed). Valid → 200. """ # Test without authentication response = api_client.get(url) - assert response.status_code == 403 + assert response.status_code == 401 # Test with invalid authentication - response = api_client.get(url, HTTP_AUTHORIZATION="Bearer invalid_token") - assert response.status_code == 403 + response = api_client.get( + url, + HTTP_X_CHANNEL_ID=str(uuid.uuid4()), + HTTP_X_API_KEY="invalid_token", + ) + assert response.status_code == 401 # Test with authentication response = api_client.get(url, **correctly_configured_header) diff --git a/src/backend/core/tests/api/test_provisioning_mailbox.py b/src/backend/core/tests/api/test_provisioning_mailbox.py new file mode 100644 index 00000000..d0d31166 --- /dev/null +++ b/src/backend/core/tests/api/test_provisioning_mailbox.py @@ -0,0 +1,422 @@ +"""Tests for the provisioning mailbox lookup endpoint.""" +# pylint: disable=redefined-outer-name,missing-function-docstring + +import uuid + +from django.urls import reverse + +import pytest +from rest_framework.test import APIClient + +from core.enums import ( + ChannelApiKeyScope, + ChannelScopeLevel, + MailboxRoleChoices, +) +from core.factories import ( + MailboxAccessFactory, + MailboxFactory, + MailDomainFactory, + UserFactory, + make_api_key_channel, +) + +MAILBOX_URL = reverse("provisioning-mailboxes") + + +def _make_api_key_channel(scopes=(ChannelApiKeyScope.MAILBOXES_READ.value,), **kwargs): + """Wrapper around the shared factory pre-loaded with the + provisioning-endpoint default scope (mailboxes:read). Callers can + still override ``scopes`` and any other kwarg.""" + return make_api_key_channel(scopes=scopes, **kwargs) + + +@pytest.fixture +def auth_header(): + """Global-scope api_key with mailboxes:read.""" + channel, plaintext = _make_api_key_channel() + return { + "HTTP_X_CHANNEL_ID": str(channel.id), + "HTTP_X_API_KEY": plaintext, + } + + +@pytest.fixture +def domain(): + return MailDomainFactory(name="company.com") + + +@pytest.fixture +def mailbox(domain): + return MailboxFactory(local_part="contact", domain=domain) + + +# ============================================================================= +# Security — API key required, no bypass possible +# ============================================================================= + + +@pytest.mark.django_db +class TestServiceAuthSecurity: + """Verify that the provisioning endpoint requires ChannelApiKeyScope.MAILBOXES_READ.""" + + def test_user_email_no_auth_returns_401(self, client): + response = client.get(MAILBOX_URL, {"user_email": "a@b.com"}) + assert response.status_code == 401 + + def test_user_email_wrong_token_returns_401(self, client): + channel, _plaintext = _make_api_key_channel() + response = client.get( + MAILBOX_URL, + {"user_email": "a@b.com"}, + HTTP_X_CHANNEL_ID=str(channel.id), + HTTP_X_API_KEY="not-the-real-key", + ) + assert response.status_code == 401 + + def test_user_email_unknown_channel_returns_401(self, client): + response = client.get( + MAILBOX_URL, + {"user_email": "a@b.com"}, + HTTP_X_CHANNEL_ID=str(uuid.uuid4()), + HTTP_X_API_KEY="anything", + ) + assert response.status_code == 401 + + def test_user_email_malformed_channel_returns_401(self, client): + """A non-UUID X-Channel-Id must be rejected by the auth class + before any DB lookup. Exercises the ValueError/ValidationError + branch in ChannelApiKeyAuthentication, distinct from the + DoesNotExist branch covered by the unknown-channel test above.""" + response = client.get( + MAILBOX_URL, + {"user_email": "a@b.com"}, + HTTP_X_CHANNEL_ID="not-a-uuid", + HTTP_X_API_KEY="anything", + ) + assert response.status_code == 401 + + def test_user_email_wrong_scope_returns_403(self, client): + channel, plaintext = _make_api_key_channel( + scopes=(ChannelApiKeyScope.METRICS_READ.value,), + ) + response = client.get( + MAILBOX_URL, + {"user_email": "a@b.com"}, + HTTP_X_CHANNEL_ID=str(channel.id), + HTTP_X_API_KEY=plaintext, + ) + assert response.status_code == 403 + + def test_email_no_auth_returns_401(self, client): + response = client.get(MAILBOX_URL, {"email": "a@b.com"}) + assert response.status_code == 401 + + def test_oidc_user_cannot_use_user_email_param(self, mailbox): + user = UserFactory(email="attacker@oidc.example.com") + MailboxAccessFactory(mailbox=mailbox, user=user, role=MailboxRoleChoices.VIEWER) + + api_client = APIClient() + api_client.force_authenticate(user=user) + + response = api_client.get( + MAILBOX_URL, {"user_email": "victim@oidc.example.com"} + ) + assert response.status_code == 403 + + def test_oidc_user_cannot_use_email_param(self, mailbox): + user = UserFactory(email="attacker@oidc.example.com") + MailboxAccessFactory(mailbox=mailbox, user=user, role=MailboxRoleChoices.VIEWER) + + api_client = APIClient() + api_client.force_authenticate(user=user) + + response = api_client.get(MAILBOX_URL, {"email": "contact@company.com"}) + assert response.status_code == 403 + + def test_no_query_param_returns_400(self, client, auth_header): + response = client.get(MAILBOX_URL, **auth_header) + assert response.status_code == 400 + + +# ============================================================================= +# GET /provisioning/mailboxes/?user_email=... +# ============================================================================= + + +@pytest.mark.django_db +class TestMailboxListByUser: + """Tests for listing mailboxes by user_email.""" + + def test_returns_mailboxes_for_user(self, client, auth_header, mailbox): + user = UserFactory(email="alice@oidc.example.com") + MailboxAccessFactory(mailbox=mailbox, user=user, role=MailboxRoleChoices.ADMIN) + + response = client.get( + MAILBOX_URL, + {"user_email": "alice@oidc.example.com"}, + **auth_header, + ) + + assert response.status_code == 200 + results = response.json()["results"] + assert len(results) == 1 + assert results[0]["id"] == str(mailbox.id) + assert results[0]["email"] == "contact@company.com" + assert results[0]["role"] == "admin" + + def test_returns_multiple_mailboxes_with_roles(self, client, auth_header, domain): + user = UserFactory(email="bob@oidc.example.com") + mb1 = MailboxFactory(local_part="info", domain=domain) + mb2 = MailboxFactory(local_part="support", domain=domain) + + MailboxAccessFactory(mailbox=mb1, user=user, role=MailboxRoleChoices.SENDER) + MailboxAccessFactory(mailbox=mb2, user=user, role=MailboxRoleChoices.VIEWER) + + response = client.get( + MAILBOX_URL, + {"user_email": "bob@oidc.example.com"}, + **auth_header, + ) + + assert response.status_code == 200 + results = response.json()["results"] + assert len(results) == 2 + + by_email = {r["email"]: r for r in results} + assert by_email["info@company.com"]["role"] == "sender" + assert by_email["support@company.com"]["role"] == "viewer" + + def test_returns_empty_for_unknown_user(self, client, auth_header): + response = client.get( + MAILBOX_URL, + {"user_email": "nobody@nowhere.com"}, + **auth_header, + ) + + assert response.status_code == 200 + assert response.json()["results"] == [] + + def test_response_fields(self, client, auth_header, mailbox): + """Response includes id, email, name, role, and users.""" + user = UserFactory(email="alice@oidc.example.com") + MailboxAccessFactory(mailbox=mailbox, user=user, role=MailboxRoleChoices.ADMIN) + + response = client.get( + MAILBOX_URL, + {"user_email": "alice@oidc.example.com"}, + **auth_header, + ) + + result = response.json()["results"][0] + assert set(result.keys()) == {"id", "email", "name", "role", "users"} + + def test_users_includes_all_mailbox_users(self, client, auth_header, mailbox): + """The users array lists ALL users with access, not just the queried one.""" + alice = UserFactory(email="alice@oidc.example.com") + bob = UserFactory(email="bob@oidc.example.com") + + MailboxAccessFactory(mailbox=mailbox, user=alice, role=MailboxRoleChoices.ADMIN) + MailboxAccessFactory(mailbox=mailbox, user=bob, role=MailboxRoleChoices.SENDER) + + response = client.get( + MAILBOX_URL, + {"user_email": "alice@oidc.example.com"}, + **auth_header, + ) + + result = response.json()["results"][0] + users_by_email = {u["email"]: u for u in result["users"]} + assert len(users_by_email) == 2 + assert users_by_email["alice@oidc.example.com"]["role"] == "admin" + assert users_by_email["bob@oidc.example.com"]["role"] == "sender" + + +# ============================================================================= +# GET /provisioning/mailboxes/?email=... +# ============================================================================= + + +@pytest.mark.django_db +class TestMailboxListByEmail: + """Tests for looking up a mailbox by its email address.""" + + def test_returns_mailbox_by_email(self, client, auth_header, mailbox): + alice = UserFactory(email="alice@oidc.example.com") + MailboxAccessFactory(mailbox=mailbox, user=alice, role=MailboxRoleChoices.ADMIN) + + response = client.get( + MAILBOX_URL, + {"email": "contact@company.com"}, + **auth_header, + ) + + assert response.status_code == 200 + results = response.json()["results"] + assert len(results) == 1 + assert results[0]["id"] == str(mailbox.id) + assert results[0]["email"] == "contact@company.com" + # No top-level role in email lookup mode + assert "role" not in results[0] + + def test_email_lookup_includes_users(self, client, auth_header, mailbox): + """Email lookup also includes the users array.""" + alice = UserFactory(email="alice@oidc.example.com") + bob = UserFactory(email="bob@oidc.example.com") + MailboxAccessFactory(mailbox=mailbox, user=alice, role=MailboxRoleChoices.ADMIN) + MailboxAccessFactory(mailbox=mailbox, user=bob, role=MailboxRoleChoices.VIEWER) + + response = client.get( + MAILBOX_URL, + {"email": "contact@company.com"}, + **auth_header, + ) + + result = response.json()["results"][0] + assert "users" in result + users_by_email = {u["email"]: u for u in result["users"]} + assert users_by_email["alice@oidc.example.com"]["role"] == "admin" + assert users_by_email["bob@oidc.example.com"]["role"] == "viewer" + + def test_returns_empty_for_unknown_email(self, client, auth_header): + response = client.get( + MAILBOX_URL, + {"email": "nope@nowhere.com"}, + **auth_header, + ) + + assert response.status_code == 200 + assert response.json()["results"] == [] + + def test_returns_empty_for_invalid_email(self, client, auth_header): + response = client.get( + MAILBOX_URL, + {"email": "no-at-sign"}, + **auth_header, + ) + + assert response.status_code == 200 + assert response.json()["results"] == [] + + +# ============================================================================= +# add_maildomain_custom_attributes +# ============================================================================= + + +@pytest.mark.django_db +class TestMaildomainCustomAttributes: + """Test the add_maildomain_custom_attributes query parameter.""" + + def test_user_email_with_custom_attributes(self, client, auth_header, domain): + domain.custom_attributes = {"siret": "123456789", "org_name": "ACME"} + domain.save() + mb = MailboxFactory(local_part="info", domain=domain) + user = UserFactory(email="alice@oidc.example.com") + MailboxAccessFactory(mailbox=mb, user=user, role=MailboxRoleChoices.ADMIN) + + response = client.get( + MAILBOX_URL, + { + "user_email": "alice@oidc.example.com", + "add_maildomain_custom_attributes": "siret,org_name", + }, + **auth_header, + ) + + assert response.status_code == 200 + result = response.json()["results"][0] + assert result["maildomain_custom_attributes"]["siret"] == "123456789" + assert result["maildomain_custom_attributes"]["org_name"] == "ACME" + + def test_email_with_custom_attributes(self, client, auth_header, domain): + domain.custom_attributes = {"siret": "987654321"} + domain.save() + MailboxFactory(local_part="info", domain=domain) + + response = client.get( + MAILBOX_URL, + { + "email": "info@company.com", + "add_maildomain_custom_attributes": "siret", + }, + **auth_header, + ) + + assert response.status_code == 200 + result = response.json()["results"][0] + assert result["maildomain_custom_attributes"]["siret"] == "987654321" + + def test_missing_key_returns_none(self, client, auth_header, domain): + domain.custom_attributes = {"siret": "123"} + domain.save() + mb = MailboxFactory(local_part="info", domain=domain) + user = UserFactory(email="alice@oidc.example.com") + MailboxAccessFactory(mailbox=mb, user=user, role=MailboxRoleChoices.ADMIN) + + response = client.get( + MAILBOX_URL, + { + "user_email": "alice@oidc.example.com", + "add_maildomain_custom_attributes": "siret,nonexistent", + }, + **auth_header, + ) + + result = response.json()["results"][0] + assert result["maildomain_custom_attributes"]["siret"] == "123" + assert result["maildomain_custom_attributes"]["nonexistent"] is None + + def test_no_param_means_no_field(self, client, auth_header, domain): + """Without the param, maildomain_custom_attributes is absent.""" + domain.custom_attributes = {"siret": "123"} + domain.save() + mb = MailboxFactory(local_part="info", domain=domain) + user = UserFactory(email="alice@oidc.example.com") + MailboxAccessFactory(mailbox=mb, user=user, role=MailboxRoleChoices.ADMIN) + + response = client.get( + MAILBOX_URL, + {"user_email": "alice@oidc.example.com"}, + **auth_header, + ) + + result = response.json()["results"][0] + assert "maildomain_custom_attributes" not in result + + +# ============================================================================= +# ProvisioningMailboxView is global-only — non-global api_key channels are +# rejected, regardless of which scope filter would otherwise narrow results. +# ============================================================================= + + +@pytest.mark.django_db +class TestMailboxListGlobalOnly: + """The endpoint refuses any non-global api_key channel.""" + + def test_maildomain_scope_returns_403(self, client, domain): + channel, plaintext = _make_api_key_channel( + scope_level=ChannelScopeLevel.MAILDOMAIN, + maildomain=domain, + ) + response = client.get( + MAILBOX_URL, + {"user_email": "alice@oidc.example.com"}, + HTTP_X_CHANNEL_ID=str(channel.id), + HTTP_X_API_KEY=plaintext, + ) + assert response.status_code == 403 + + def test_mailbox_scope_returns_403(self, client, mailbox): + channel, plaintext = _make_api_key_channel( + scope_level=ChannelScopeLevel.MAILBOX, + mailbox=mailbox, + ) + response = client.get( + MAILBOX_URL, + {"email": "contact@company.com"}, + HTTP_X_CHANNEL_ID=str(channel.id), + HTTP_X_API_KEY=plaintext, + ) + assert response.status_code == 403 diff --git a/src/backend/core/tests/api/test_provisioning_maildomains.py b/src/backend/core/tests/api/test_provisioning_maildomains.py index b1f0792b..e3630bdf 100644 --- a/src/backend/core/tests/api/test_provisioning_maildomains.py +++ b/src/backend/core/tests/api/test_provisioning_maildomains.py @@ -1,14 +1,26 @@ """Tests for the provisioning maildomains endpoint.""" # pylint: disable=redefined-outer-name +import uuid + from django.urls import reverse import pytest -from core.factories import MailDomainFactory +from core.enums import ChannelApiKeyScope, ChannelScopeLevel +from core.factories import MailDomainFactory, make_api_key_channel from core.models import MailDomain +def _make_api_key_channel( + scopes=(ChannelApiKeyScope.MAILDOMAINS_CREATE.value,), **kwargs +): + """Wrapper around the shared factory pre-loaded with the + maildomains-write scope used by every test in this module.""" + kwargs.setdefault("name", "provisioning-test") + return make_api_key_channel(scopes=scopes, **kwargs) + + @pytest.fixture def url(): """Returns the URL for the provisioning maildomains endpoint.""" @@ -16,46 +28,68 @@ def url(): @pytest.fixture -def auth_header(settings): - """Returns the authentication header for the provisioning endpoint.""" - settings.PROVISIONING_API_KEY = "test-provisioning-key" - return {"HTTP_AUTHORIZATION": "Bearer test-provisioning-key"} +def auth_header(): + """Global-scope api_key with ChannelApiKeyScope.MAILDOMAINS_CREATE.""" + channel, plaintext = _make_api_key_channel() + return { + "HTTP_X_CHANNEL_ID": str(channel.id), + "HTTP_X_API_KEY": plaintext, + } # -- Authentication tests -- @pytest.mark.django_db -def test_provisioning_no_auth_returns_403(client, url): - """Request without Authorization header returns 403.""" +def test_provisioning_no_auth_returns_401(client, url): + """Request without X-Channel-Id/X-API-Key returns 401 (not authenticated).""" response = client.post( url, data={"domains": ["test.fr"]}, content_type="application/json" ) - assert response.status_code == 403 + assert response.status_code == 401 @pytest.mark.django_db -def test_provisioning_wrong_token_returns_403(client, url, settings): - """Request with wrong token returns 403.""" - settings.PROVISIONING_API_KEY = "correct-key" +def test_provisioning_wrong_token_returns_401(client, url): + """Request with wrong token returns 401 (invalid credentials).""" + channel, _plaintext = _make_api_key_channel() response = client.post( url, data={"domains": ["test.fr"]}, content_type="application/json", - HTTP_AUTHORIZATION="Bearer wrong-key", + HTTP_X_CHANNEL_ID=str(channel.id), + HTTP_X_API_KEY="not-the-real-key", ) - assert response.status_code == 403 + assert response.status_code == 401 @pytest.mark.django_db -def test_provisioning_no_key_configured_returns_403(client, url, settings): - """When PROVISIONING_API_KEY is not configured, returns 403.""" - settings.PROVISIONING_API_KEY = None +def test_provisioning_unknown_channel_returns_401(client, url): + """Unknown channel id returns 401 (invalid credentials).""" response = client.post( url, data={"domains": ["test.fr"]}, content_type="application/json", - HTTP_AUTHORIZATION="Bearer some-key", + HTTP_X_CHANNEL_ID=str(uuid.uuid4()), + HTTP_X_API_KEY="anything", + ) + assert response.status_code == 401 + + +@pytest.mark.django_db +def test_provisioning_non_global_scope_returns_403(client, url): + """A maildomain-scope key cannot call maildomain provisioning.""" + domain = MailDomainFactory(name="test.fr") + channel, plaintext = _make_api_key_channel( + scope_level=ChannelScopeLevel.MAILDOMAIN, + maildomain=domain, + ) + response = client.post( + url, + data={"domains": ["new.fr"]}, + content_type="application/json", + HTTP_X_CHANNEL_ID=str(channel.id), + HTTP_X_API_KEY=plaintext, ) assert response.status_code == 403 diff --git a/src/backend/core/tests/api/test_submit.py b/src/backend/core/tests/api/test_submit.py new file mode 100644 index 00000000..a5a0eb2b --- /dev/null +++ b/src/backend/core/tests/api/test_submit.py @@ -0,0 +1,658 @@ +"""Tests for the generic email submission endpoint (POST /submit/).""" +# pylint: disable=redefined-outer-name,missing-function-docstring,unused-argument,import-outside-toplevel + +import uuid +from unittest.mock import MagicMock, patch + +import pytest +from dkim import verify as dkim_verify + +from core.enums import ChannelApiKeyScope, ChannelScopeLevel +from core.factories import MailboxFactory, MailDomainFactory, make_api_key_channel +from core.mda.signing import generate_dkim_key + +SUBMIT_URL = "/api/v1.0/submit/" + +MINIMAL_MIME = ( + b"From: contact@company.com\r\n" + b"To: attendee@example.com\r\n" + b"Subject: Team Meeting\r\n" + b"Message-ID: \r\n" + b"Date: Mon, 30 Mar 2026 10:00:00 +0000\r\n" + b"MIME-Version: 1.0\r\n" + b"Content-Type: text/plain; charset=utf-8\r\n" + b"\r\n" + b"Hello world\r\n" +) + +CREATE_MSG_MOCK = "core.api.viewsets.submit._create_message_from_inbound" +PREPARE_MOCK = "core.api.viewsets.submit.prepare_outbound_message" +TASK_MOCK = "core.api.viewsets.submit.send_message_task" + + +def _make_api_key_channel(**kwargs): + """Thin wrapper around the shared factory pre-loaded with the + submit-endpoint default scope (messages:send).""" + kwargs.setdefault("scopes", (ChannelApiKeyScope.MESSAGES_SEND.value,)) + kwargs.setdefault("name", "test-key") + return make_api_key_channel(**kwargs) + + +@pytest.fixture +def auth_header(): + """Build a global-scope api_key with messages:send and return the auth headers.""" + channel, plaintext = _make_api_key_channel() + return { + "HTTP_X_CHANNEL_ID": str(channel.id), + "HTTP_X_API_KEY": plaintext, + } + + +@pytest.fixture +def domain(): + return MailDomainFactory(name="company.com") + + +@pytest.fixture +def mailbox(domain): + return MailboxFactory(local_part="contact", domain=domain) + + +# ============================================================================= +# Authentication +# ============================================================================= + + +@pytest.mark.django_db +class TestSubmitAuth: + """Authentication tests for the submit endpoint.""" + + def test_no_auth_returns_401(self, client, mailbox): + """No auth headers → 401 via DRF NotAuthenticated.""" + response = client.post( + SUBMIT_URL, + data=MINIMAL_MIME, + content_type="message/rfc822", + HTTP_X_MAIL_FROM=str(mailbox.id), + HTTP_X_RCPT_TO="attendee@example.com", + ) + assert response.status_code == 401 + + def test_wrong_token_returns_401(self, client, mailbox): + """Invalid credentials are an authentication failure → 401.""" + channel, _plaintext = _make_api_key_channel() + response = client.post( + SUBMIT_URL, + data=MINIMAL_MIME, + content_type="message/rfc822", + HTTP_X_CHANNEL_ID=str(channel.id), + HTTP_X_API_KEY="not-the-real-key", + HTTP_X_MAIL_FROM=str(mailbox.id), + HTTP_X_RCPT_TO="attendee@example.com", + ) + assert response.status_code == 401 + + def test_unknown_channel_returns_401(self, client, mailbox): + response = client.post( + SUBMIT_URL, + data=MINIMAL_MIME, + content_type="message/rfc822", + HTTP_X_CHANNEL_ID=str(uuid.uuid4()), + HTTP_X_API_KEY="anything", + HTTP_X_MAIL_FROM=str(mailbox.id), + HTTP_X_RCPT_TO="attendee@example.com", + ) + assert response.status_code == 401 + + def test_missing_scope_returns_403(self, client, mailbox): + channel, plaintext = _make_api_key_channel( + scopes=(ChannelApiKeyScope.MAILBOXES_READ.value,), + ) + response = client.post( + SUBMIT_URL, + data=MINIMAL_MIME, + content_type="message/rfc822", + HTTP_X_CHANNEL_ID=str(channel.id), + HTTP_X_API_KEY=plaintext, + HTTP_X_MAIL_FROM=str(mailbox.id), + HTTP_X_RCPT_TO="attendee@example.com", + ) + assert response.status_code == 403 + + def test_mailbox_scope_wrong_mailbox_returns_403(self, client, domain, mailbox): + other_mailbox = MailboxFactory(local_part="other", domain=domain) + channel, plaintext = _make_api_key_channel( + scope_level=ChannelScopeLevel.MAILBOX, + mailbox=other_mailbox, + ) + response = client.post( + SUBMIT_URL, + data=MINIMAL_MIME, # from contact@company.com + content_type="message/rfc822", + HTTP_X_CHANNEL_ID=str(channel.id), + HTTP_X_API_KEY=plaintext, + HTTP_X_MAIL_FROM=str(mailbox.id), # contact@company.com + HTTP_X_RCPT_TO="attendee@example.com", + ) + assert response.status_code == 403 + + def test_maildomain_scope_wrong_domain_returns_403(self, client, mailbox): + other_domain = MailDomainFactory(name="other.test") + channel, plaintext = _make_api_key_channel( + scope_level=ChannelScopeLevel.MAILDOMAIN, + maildomain=other_domain, + ) + response = client.post( + SUBMIT_URL, + data=MINIMAL_MIME, + content_type="message/rfc822", + HTTP_X_CHANNEL_ID=str(channel.id), + HTTP_X_API_KEY=plaintext, + HTTP_X_MAIL_FROM=str(mailbox.id), + HTTP_X_RCPT_TO="attendee@example.com", + ) + assert response.status_code == 403 + + def test_get_method_not_allowed(self, client, auth_header, mailbox): + response = client.get( + SUBMIT_URL, + HTTP_X_MAIL_FROM=str(mailbox.id), + HTTP_X_RCPT_TO="attendee@example.com", + **auth_header, + ) + assert response.status_code == 405 + + @pytest.mark.parametrize( + "role_name", + ["VIEWER", "EDITOR"], + ) + def test_user_scope_non_sending_role_cannot_submit( + self, client, mailbox, role_name + ): + """Regression: a user-scope api_key whose owner does not have a + SENDER-or-better role on the mailbox MUST NOT be able to submit. + Both VIEWER and EDITOR are below the threshold — + ``MAILBOX_ROLES_CAN_SEND = [SENDER, ADMIN]`` — so neither can + send through a personal api_key. The fix is in + ``Channel.api_key_covers``'s ``mailbox_roles=`` path.""" + from core.enums import MailboxRoleChoices + from core.factories import MailboxAccessFactory, UserFactory + + owner = UserFactory(email=f"{role_name.lower()}@oidc.example.com") + MailboxAccessFactory( + mailbox=mailbox, + user=owner, + role=getattr(MailboxRoleChoices, role_name), + ) + + channel, plaintext = _make_api_key_channel( + scope_level=ChannelScopeLevel.USER, + user=owner, + name=f"{role_name.lower()}-personal", + ) + + response = client.post( + SUBMIT_URL, + data=MINIMAL_MIME, + content_type="message/rfc822", + HTTP_X_CHANNEL_ID=str(channel.id), + HTTP_X_API_KEY=plaintext, + HTTP_X_MAIL_FROM=str(mailbox.id), + HTTP_X_RCPT_TO="attendee@example.com", + ) + assert response.status_code == 403, response.content + + @pytest.mark.parametrize( + "role_name", + ["SENDER", "ADMIN"], + ) + def test_user_scope_sending_role_can_submit(self, client, mailbox, role_name): + """Companion to the negative test: a user-scope api_key whose + owner has SENDER or ADMIN access *is* allowed through. Both roles + are in ``MAILBOX_ROLES_CAN_SEND``. The pipeline is mocked so this + test only exercises the auth+permission+covers layer.""" + from core.enums import MailboxRoleChoices + from core.factories import MailboxAccessFactory, UserFactory + + owner = UserFactory(email=f"{role_name.lower()}@oidc.example.com") + MailboxAccessFactory( + mailbox=mailbox, + user=owner, + role=getattr(MailboxRoleChoices, role_name), + ) + + channel, plaintext = _make_api_key_channel( + scope_level=ChannelScopeLevel.USER, + user=owner, + name=f"{role_name.lower()}-personal", + ) + + fake_message = MagicMock() + fake_message.id = uuid.uuid4() + fake_message.recipients.values_list.return_value = [] + + with ( + patch(CREATE_MSG_MOCK, return_value=fake_message), + patch(PREPARE_MOCK, return_value=True), + patch(TASK_MOCK), + ): + response = client.post( + SUBMIT_URL, + data=MINIMAL_MIME, + content_type="message/rfc822", + HTTP_X_CHANNEL_ID=str(channel.id), + HTTP_X_API_KEY=plaintext, + HTTP_X_MAIL_FROM=str(mailbox.id), + HTTP_X_RCPT_TO="attendee@example.com", + ) + assert response.status_code == 202, response.content + + +# ============================================================================= +# Validation +# ============================================================================= + + +@pytest.mark.django_db +class TestSubmitValidation: + """Input validation tests.""" + + def test_missing_both_headers_returns_400(self, client, auth_header): + response = client.post( + SUBMIT_URL, + data=MINIMAL_MIME, + content_type="message/rfc822", + **auth_header, + ) + assert response.status_code == 400 + assert "X-Mail-From" in response.json()["detail"] + + def test_missing_x_rcpt_to_returns_400(self, client, auth_header, mailbox): + response = client.post( + SUBMIT_URL, + data=MINIMAL_MIME, + content_type="message/rfc822", + HTTP_X_MAIL_FROM=str(mailbox.id), + **auth_header, + ) + assert response.status_code == 400 + + def test_missing_x_mail_from_returns_400(self, client, auth_header): + response = client.post( + SUBMIT_URL, + data=MINIMAL_MIME, + content_type="message/rfc822", + HTTP_X_RCPT_TO="attendee@example.com", + **auth_header, + ) + assert response.status_code == 400 + + def test_unknown_mailbox_returns_404(self, client, auth_header): + response = client.post( + SUBMIT_URL, + data=MINIMAL_MIME, + content_type="message/rfc822", + HTTP_X_MAIL_FROM=str(uuid.uuid4()), + HTTP_X_RCPT_TO="attendee@example.com", + **auth_header, + ) + assert response.status_code == 404 + + def test_invalid_uuid_in_x_mail_from_returns_404(self, client, auth_header): + response = client.post( + SUBMIT_URL, + data=MINIMAL_MIME, + content_type="message/rfc822", + HTTP_X_MAIL_FROM="not-a-uuid", + HTTP_X_RCPT_TO="attendee@example.com", + **auth_header, + ) + assert response.status_code == 404 + + def test_empty_body_returns_400(self, client, auth_header, mailbox): + response = client.post( + SUBMIT_URL, + data=b"", + content_type="message/rfc822", + HTTP_X_MAIL_FROM=str(mailbox.id), + HTTP_X_RCPT_TO="attendee@example.com", + **auth_header, + ) + assert response.status_code == 400 + + def test_sender_mismatch_returns_403(self, client, auth_header, domain): + other_mailbox = MailboxFactory(local_part="other", domain=domain) + response = client.post( + SUBMIT_URL, + data=MINIMAL_MIME, # From: contact@company.com + content_type="message/rfc822", + HTTP_X_MAIL_FROM=str(other_mailbox.id), # other@company.com + HTTP_X_RCPT_TO="attendee@example.com", + **auth_header, + ) + assert response.status_code == 403 + + def test_empty_rcpt_to_returns_400(self, client, auth_header, mailbox): + response = client.post( + SUBMIT_URL, + data=MINIMAL_MIME, + content_type="message/rfc822", + HTTP_X_MAIL_FROM=str(mailbox.id), + HTTP_X_RCPT_TO=" , ", + **auth_header, + ) + assert response.status_code == 400 + + +# ============================================================================= +# Message creation + DKIM signing + async dispatch +# ============================================================================= + + +@pytest.mark.django_db +class TestSubmitDispatch: + """Verify message creation, synchronous signing, and async dispatch.""" + + def _fake_message(self): + """Create a fake Message with a recipients manager stub.""" + msg = MagicMock() + msg.id = uuid.uuid4() + msg.recipients.values_list.return_value = ["attendee@example.com"] + return msg + + @patch(TASK_MOCK) + @patch(PREPARE_MOCK, return_value=True) + @patch(CREATE_MSG_MOCK) + def test_accepted( + self, mock_create, mock_prepare, mock_task, client, auth_header, mailbox + ): + fake_message = self._fake_message() + mock_create.return_value = fake_message + + response = client.post( + SUBMIT_URL, + data=MINIMAL_MIME, + content_type="message/rfc822", + HTTP_X_MAIL_FROM=str(mailbox.id), + HTTP_X_RCPT_TO="attendee@example.com", + **auth_header, + ) + + assert response.status_code == 202 + data = response.json() + assert data["status"] == "accepted" + assert data["message_id"] == str(fake_message.id) + + # Message created with is_outbound=True + mock_create.assert_called_once() + assert mock_create.call_args[1]["is_outbound"] is True + assert mock_create.call_args[1]["mailbox"] == mailbox + + # prepare_outbound_message called with raw_mime + mock_prepare.assert_called_once() + assert mock_prepare.call_args[1]["raw_mime"] == MINIMAL_MIME + + # Async task dispatched + mock_task.delay.assert_called_once_with(str(fake_message.id)) + + @patch(TASK_MOCK) + @patch(PREPARE_MOCK, return_value=True) + @patch(CREATE_MSG_MOCK) + def test_create_message_failure_returns_500( + self, mock_create, mock_prepare, mock_task, client, auth_header, mailbox + ): + mock_create.return_value = None + + response = client.post( + SUBMIT_URL, + data=MINIMAL_MIME, + content_type="message/rfc822", + HTTP_X_MAIL_FROM=str(mailbox.id), + HTTP_X_RCPT_TO="attendee@example.com", + **auth_header, + ) + + assert response.status_code == 500 + assert "create message" in response.json()["detail"].lower() + mock_prepare.assert_not_called() + mock_task.delay.assert_not_called() + + @patch(TASK_MOCK) + @patch(PREPARE_MOCK, return_value=False) + @patch(CREATE_MSG_MOCK) + def test_prepare_failure_returns_500( + self, mock_create, mock_prepare, mock_task, client, auth_header, mailbox + ): + mock_create.return_value = self._fake_message() + + response = client.post( + SUBMIT_URL, + data=MINIMAL_MIME, + content_type="message/rfc822", + HTTP_X_MAIL_FROM=str(mailbox.id), + HTTP_X_RCPT_TO="attendee@example.com", + **auth_header, + ) + + assert response.status_code == 500 + assert "prepare" in response.json()["detail"].lower() + mock_task.delay.assert_not_called() + + +# ============================================================================= +# Integration — real pipeline, only mock the async SMTP delivery +# ============================================================================= + + +@pytest.mark.django_db +class TestSubmitIntegration: + """End-to-end tests that run the full pipeline (message creation, DKIM + signing, blob storage) and only mock the final async SMTP task.""" + + @patch(TASK_MOCK) + def test_full_pipeline(self, mock_task, client, auth_header, mailbox): + """Submit creates a Message with thread, recipients, blob, and dispatches delivery.""" + mailbox_email = str(mailbox) + # X-Rcpt-To matches the To: header in MINIMAL_MIME (attendee@example.com) + rcpt_to = "attendee@example.com" + + response = client.post( + SUBMIT_URL, + data=MINIMAL_MIME, + content_type="message/rfc822", + HTTP_X_MAIL_FROM=str(mailbox.id), + HTTP_X_RCPT_TO=rcpt_to, + **auth_header, + ) + + assert response.status_code == 202 + data = response.json() + assert data["status"] == "accepted" + message_id = data["message_id"] + + # Verify message in DB + from core.models import Message, ThreadAccess + + message = Message.objects.get(id=message_id) + assert message.subject == "Team Meeting" + assert message.is_sender is True + assert message.is_draft is False # finalized by prepare_outbound_message + assert message.sender.email == mailbox_email + assert message.blob is not None # DKIM-signed MIME stored + + # Thread was created and mailbox has access + assert message.thread is not None + assert ThreadAccess.objects.filter( + thread=message.thread, mailbox=mailbox + ).exists() + + # Recipient was created (from the parsed To: header) + assert message.recipients.filter(contact__email=rcpt_to).exists() + + # Async delivery dispatched + mock_task.delay.assert_called_once_with(str(message.id)) + + @patch(TASK_MOCK) + def test_multiple_recipients_creates_all( + self, mock_task, client, auth_header, mailbox + ): + """Multiple X-Rcpt-To addresses each get a recipient record.""" + mailbox_email = str(mailbox) + mime = ( + f"From: {mailbox_email}\r\n" + f"To: a@example.com, b@example.com\r\n" + f"Subject: Multi\r\n" + f"Message-ID: \r\n" + f"Date: Mon, 30 Mar 2026 10:00:00 +0000\r\n" + f"MIME-Version: 1.0\r\n" + f"Content-Type: text/plain\r\n" + f"\r\n" + f"body\r\n" + ).encode() + + response = client.post( + SUBMIT_URL, + data=mime, + content_type="message/rfc822", + HTTP_X_MAIL_FROM=str(mailbox.id), + HTTP_X_RCPT_TO="a@example.com, b@example.com", + **auth_header, + ) + + assert response.status_code == 202 + from core.models import Message + + message = Message.objects.get(id=response.json()["message_id"]) + recipient_emails = set( + message.recipients.values_list("contact__email", flat=True) + ) + assert "a@example.com" in recipient_emails + assert "b@example.com" in recipient_emails + + @patch(TASK_MOCK) + def test_bcc_via_envelope(self, mock_task, client, auth_header, mailbox): + """BCC works via X-Rcpt-To: the recipient is in the envelope but NOT + in the MIME headers — just like real SMTP BCC.""" + mailbox_email = str(mailbox) + # MIME only has To: visible@example.com — no Bcc header + mime = ( + f"From: {mailbox_email}\r\n" + f"To: visible@example.com\r\n" + f"Subject: With Bcc\r\n" + f"Message-ID: \r\n" + f"Date: Mon, 30 Mar 2026 10:00:00 +0000\r\n" + f"MIME-Version: 1.0\r\n" + f"Content-Type: text/plain\r\n" + f"\r\n" + f"body\r\n" + ).encode() + + # X-Rcpt-To includes both visible and hidden (BCC) recipients + response = client.post( + SUBMIT_URL, + data=mime, + content_type="message/rfc822", + HTTP_X_MAIL_FROM=str(mailbox.id), + HTTP_X_RCPT_TO="visible@example.com, hidden@example.com", + **auth_header, + ) + + assert response.status_code == 202 + from core.enums import MessageRecipientTypeChoices + from core.models import Message + + message = Message.objects.get(id=response.json()["message_id"]) + + # visible@example.com comes from MIME To: header + assert message.recipients.filter( + contact__email="visible@example.com", + type=MessageRecipientTypeChoices.TO, + ).exists() + + # hidden@example.com comes from X-Rcpt-To only — added as BCC + assert message.recipients.filter( + contact__email="hidden@example.com", + type=MessageRecipientTypeChoices.BCC, + ).exists() + + @patch(TASK_MOCK) + def test_cc_recipients_created(self, mock_task, client, auth_header, mailbox): + """Cc recipients from MIME headers are created as MessageRecipient rows.""" + mailbox_email = str(mailbox) + mime = ( + f"From: {mailbox_email}\r\n" + f"To: to@example.com\r\n" + f"Cc: cc@example.com\r\n" + f"Subject: With Cc\r\n" + f"Message-ID: \r\n" + f"Date: Mon, 30 Mar 2026 10:00:00 +0000\r\n" + f"MIME-Version: 1.0\r\n" + f"Content-Type: text/plain\r\n" + f"\r\n" + f"body\r\n" + ).encode() + + response = client.post( + SUBMIT_URL, + data=mime, + content_type="message/rfc822", + HTTP_X_MAIL_FROM=str(mailbox.id), + HTTP_X_RCPT_TO="to@example.com, cc@example.com", + **auth_header, + ) + + assert response.status_code == 202 + from core.enums import MessageRecipientTypeChoices + from core.models import Message + + message = Message.objects.get(id=response.json()["message_id"]) + assert message.recipients.filter( + contact__email="to@example.com", + type=MessageRecipientTypeChoices.TO, + ).exists() + assert message.recipients.filter( + contact__email="cc@example.com", + type=MessageRecipientTypeChoices.CC, + ).exists() + + @patch(TASK_MOCK) + def test_stored_blob_is_dkim_signed(self, mock_task, client, auth_header, mailbox): + """The blob persisted for the outbound message must be DKIM-signed + with the domain's active key and verifiable against its public key.""" + from core.models import DKIMKey, Message + + private_key, public_key = generate_dkim_key(key_size=1024) + dkim_key = DKIMKey.objects.create( + selector="testselector", + private_key=private_key, + public_key=public_key, + key_size=1024, + is_active=True, + domain=mailbox.domain, + ) + + response = client.post( + SUBMIT_URL, + data=MINIMAL_MIME, + content_type="message/rfc822", + HTTP_X_MAIL_FROM=str(mailbox.id), + HTTP_X_RCPT_TO="attendee@example.com", + **auth_header, + ) + assert response.status_code == 202 + + message = Message.objects.get(id=response.json()["message_id"]) + stored = message.blob.get_content() + + # Header is present and prepended before the original MIME. + assert stored.startswith(b"DKIM-Signature:"), stored[:200] + + # Verify the signature cryptographically using the stored public key. + def get_dns_txt(fqdn, **kwargs): + if fqdn == b"testselector._domainkey.%s." % mailbox.domain.name.encode(): + return f"v=DKIM1; k=rsa; p={dkim_key.public_key}".encode() + return None + + assert dkim_verify(stored, dnsfunc=get_dns_txt), ( + "DKIM verification failed on stored blob" + ) diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index f345b78c..71b02d3c 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -6,7 +6,7 @@ from django.urls import include, path from rest_framework.routers import DefaultRouter from core.api.viewsets.blob import BlobViewSet -from core.api.viewsets.channel import ChannelViewSet +from core.api.viewsets.channel import ChannelViewSet, UserChannelViewSet from core.api.viewsets.config import ConfigView from core.api.viewsets.contacts import ContactViewSet from core.api.viewsets.draft import DraftMessageView @@ -37,8 +37,12 @@ from core.api.viewsets.metrics import ( MailDomainUsersMetricsApiView, ) from core.api.viewsets.placeholder import DraftPlaceholderView, PlaceholderView -from core.api.viewsets.provisioning import ProvisioningMailDomainView +from core.api.viewsets.provisioning import ( + ProvisioningMailboxView, + ProvisioningMailDomainView, +) from core.api.viewsets.send import SendMessageView +from core.api.viewsets.submit import SubmitRawEmailView from core.api.viewsets.task import TaskDetailView from core.api.viewsets.thread import ThreadViewSet from core.api.viewsets.thread_access import ThreadAccessViewSet @@ -49,6 +53,7 @@ from core.authentication.urls import urlpatterns as oidc_urls # - Main endpoints router = DefaultRouter() +router.register("users/me/channels", UserChannelViewSet, basename="user-channels") router.register("users", UserViewSet, basename="users") router.register("messages", MessageViewSet, basename="messages") router.register("blob", BlobViewSet, basename="blob") @@ -139,6 +144,8 @@ mailbox_channel_nested_router.register( ChannelViewSet, basename="mailbox-channels", ) +# /users/me/channels/ is registered directly on the main `router` above — +# no nested router needed for a single viewset. urlpatterns = [ path( @@ -255,6 +262,17 @@ urlpatterns = [ ProvisioningMailDomainView.as_view(), name="provisioning-maildomains", ), + path( + f"api/{settings.API_VERSION}/submit/", + SubmitRawEmailView.as_view(), + name="submit-email", + ), + # Provisioning: mailbox and user lookup (service-to-service, API key auth) + path( + f"api/{settings.API_VERSION}/provisioning/mailboxes/", + ProvisioningMailboxView.as_view(), + name="provisioning-mailboxes", + ), # Alias for MTA check endpoint path( f"api/{settings.API_VERSION}/mta/check-recipients/", diff --git a/src/backend/messages/settings.py b/src/backend/messages/settings.py index a30173d7..64fb360e 100755 --- a/src/backend/messages/settings.py +++ b/src/backend/messages/settings.py @@ -798,6 +798,9 @@ class Base(Configuration): None, environ_name="PROMETHEUS_API_KEY", environ_prefix=None ) + # DEPRECATED: ignored since global api_key Channels landed. + # Kept only so AppConfig.ready() can emit a deprecation warning when + # either env var is set. Migrate to a global api_key Channel. METRICS_API_KEY = values.Value( None, environ_name="METRICS_API_KEY", environ_prefix=None ) @@ -843,8 +846,15 @@ class Base(Configuration): FEATURE_IMPORT_MESSAGES = values.BooleanValue( default=True, environ_name="FEATURE_IMPORT_MESSAGES", environ_prefix=None ) + # NOTE: "webhook" is intentionally NOT in the default list — the + # outbound webhook delivery pipeline is not wired yet. Keeping the + # type creatable would let users mint dead-letter channels that look + # functional. Add "webhook" here once core/mda/webhook_tasks.py and + # the post_save signal land. FEATURE_MAILBOX_ADMIN_CHANNELS = values.ListValue( - default=[], environ_name="FEATURE_MAILBOX_ADMIN_CHANNELS", environ_prefix=None + default=["api_key"], + environ_name="FEATURE_MAILBOX_ADMIN_CHANNELS", + environ_prefix=None, ) FEATURE_MAILDOMAIN_CREATE = values.BooleanValue( default=True, environ_name="FEATURE_MAILDOMAIN_CREATE", environ_prefix=None diff --git a/src/frontend/src/features/api/gen/channels/channels.ts b/src/frontend/src/features/api/gen/channels/channels.ts index 964046cf..8710b67f 100644 --- a/src/frontend/src/features/api/gen/channels/channels.ts +++ b/src/frontend/src/features/api/gen/channels/channels.ts @@ -25,6 +25,7 @@ import type { Channel, ChannelRequest, PatchedChannelRequest, + RegeneratedApiKeyResponse, } from ".././models"; import { fetchAPI } from "../../fetch-api"; @@ -224,30 +225,12 @@ export type mailboxesChannelsCreateResponse201 = { status: 201; }; -export type mailboxesChannelsCreateResponse400 = { - data: void; - status: 400; -}; - -export type mailboxesChannelsCreateResponse403 = { - data: void; - status: 403; -}; - export type mailboxesChannelsCreateResponseSuccess = mailboxesChannelsCreateResponse201 & { headers: Headers; }; -export type mailboxesChannelsCreateResponseError = ( - | mailboxesChannelsCreateResponse400 - | mailboxesChannelsCreateResponse403 -) & { - headers: Headers; -}; - export type mailboxesChannelsCreateResponse = - | mailboxesChannelsCreateResponseSuccess - | mailboxesChannelsCreateResponseError; + mailboxesChannelsCreateResponseSuccess; export const getMailboxesChannelsCreateUrl = (mailboxId: string) => { return `/api/v1.0/mailboxes/${mailboxId}/channels/`; @@ -270,7 +253,7 @@ export const mailboxesChannelsCreate = async ( }; export const getMailboxesChannelsCreateMutationOptions = < - TError = ErrorType, + TError = ErrorType, TContext = unknown, >(options?: { mutation?: UseMutationOptions< @@ -311,10 +294,10 @@ export type MailboxesChannelsCreateMutationResult = NonNullable< Awaited> >; export type MailboxesChannelsCreateMutationBody = ChannelRequest; -export type MailboxesChannelsCreateMutationError = ErrorType; +export type MailboxesChannelsCreateMutationError = ErrorType; export const useMailboxesChannelsCreate = < - TError = ErrorType, + TError = ErrorType, TContext = unknown, >( options?: { @@ -899,3 +882,1063 @@ export const useMailboxesChannelsDestroy = < return useMutation(mutationOptions, queryClient); }; +/** + * Manage integration channels for a mailbox + */ +export type mailboxesChannelsRegenerateApiKeyCreateResponse200 = { + data: RegeneratedApiKeyResponse; + status: 200; +}; + +export type mailboxesChannelsRegenerateApiKeyCreateResponse400 = { + data: void; + status: 400; +}; + +export type mailboxesChannelsRegenerateApiKeyCreateResponse403 = { + data: void; + status: 403; +}; + +export type mailboxesChannelsRegenerateApiKeyCreateResponse404 = { + data: void; + status: 404; +}; + +export type mailboxesChannelsRegenerateApiKeyCreateResponseSuccess = + mailboxesChannelsRegenerateApiKeyCreateResponse200 & { + headers: Headers; + }; +export type mailboxesChannelsRegenerateApiKeyCreateResponseError = ( + | mailboxesChannelsRegenerateApiKeyCreateResponse400 + | mailboxesChannelsRegenerateApiKeyCreateResponse403 + | mailboxesChannelsRegenerateApiKeyCreateResponse404 +) & { + headers: Headers; +}; + +export type mailboxesChannelsRegenerateApiKeyCreateResponse = + | mailboxesChannelsRegenerateApiKeyCreateResponseSuccess + | mailboxesChannelsRegenerateApiKeyCreateResponseError; + +export const getMailboxesChannelsRegenerateApiKeyCreateUrl = ( + mailboxId: string, + id: string, +) => { + return `/api/v1.0/mailboxes/${mailboxId}/channels/${id}/regenerate-api-key/`; +}; + +export const mailboxesChannelsRegenerateApiKeyCreate = async ( + mailboxId: string, + id: string, + options?: RequestInit, +): Promise => { + return fetchAPI( + getMailboxesChannelsRegenerateApiKeyCreateUrl(mailboxId, id), + { + ...options, + method: "POST", + }, + ); +}; + +export const getMailboxesChannelsRegenerateApiKeyCreateMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { mailboxId: string; id: string }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { mailboxId: string; id: string }, + TContext +> => { + const mutationKey = ["mailboxesChannelsRegenerateApiKeyCreate"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { mailboxId: string; id: string } + > = (props) => { + const { mailboxId, id } = props ?? {}; + + return mailboxesChannelsRegenerateApiKeyCreate( + mailboxId, + id, + requestOptions, + ); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type MailboxesChannelsRegenerateApiKeyCreateMutationResult = NonNullable< + Awaited> +>; + +export type MailboxesChannelsRegenerateApiKeyCreateMutationError = + ErrorType; + +export const useMailboxesChannelsRegenerateApiKeyCreate = < + TError = ErrorType, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { mailboxId: string; id: string }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { mailboxId: string; id: string }, + TContext +> => { + const mutationOptions = + getMailboxesChannelsRegenerateApiKeyCreateMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; +/** + * Manage personal (scope_level=user) integration channels + */ +export type usersMeChannelsListResponse200 = { + data: Channel[]; + status: 200; +}; + +export type usersMeChannelsListResponseSuccess = + usersMeChannelsListResponse200 & { + headers: Headers; + }; +export type usersMeChannelsListResponse = usersMeChannelsListResponseSuccess; + +export const getUsersMeChannelsListUrl = () => { + return `/api/v1.0/users/me/channels/`; +}; + +export const usersMeChannelsList = async ( + options?: RequestInit, +): Promise => { + return fetchAPI(getUsersMeChannelsListUrl(), { + ...options, + method: "GET", + }); +}; + +export const getUsersMeChannelsListQueryKey = () => { + return [`/api/v1.0/users/me/channels/`] as const; +}; + +export const getUsersMeChannelsListQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>(options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; +}) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getUsersMeChannelsListQueryKey(); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => usersMeChannelsList({ signal, ...requestOptions }); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type UsersMeChannelsListQueryResult = NonNullable< + Awaited> +>; +export type UsersMeChannelsListQueryError = ErrorType; + +export function useUsersMeChannelsList< + TData = Awaited>, + TError = ErrorType, +>( + options: { + query: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useUsersMeChannelsList< + TData = Awaited>, + TError = ErrorType, +>( + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useUsersMeChannelsList< + TData = Awaited>, + TError = ErrorType, +>( + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; + +export function useUsersMeChannelsList< + TData = Awaited>, + TError = ErrorType, +>( + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = getUsersMeChannelsListQueryOptions(options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey; + + return query; +} + +/** + * Manage personal (scope_level=user) integration channels + */ +export type usersMeChannelsCreateResponse201 = { + data: Channel; + status: 201; +}; + +export type usersMeChannelsCreateResponseSuccess = + usersMeChannelsCreateResponse201 & { + headers: Headers; + }; +export type usersMeChannelsCreateResponse = + usersMeChannelsCreateResponseSuccess; + +export const getUsersMeChannelsCreateUrl = () => { + return `/api/v1.0/users/me/channels/`; +}; + +export const usersMeChannelsCreate = async ( + channelRequest: ChannelRequest, + options?: RequestInit, +): Promise => { + return fetchAPI( + getUsersMeChannelsCreateUrl(), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(channelRequest), + }, + ); +}; + +export const getUsersMeChannelsCreateMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: ChannelRequest }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { data: ChannelRequest }, + TContext +> => { + const mutationKey = ["usersMeChannelsCreate"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { data: ChannelRequest } + > = (props) => { + const { data } = props ?? {}; + + return usersMeChannelsCreate(data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type UsersMeChannelsCreateMutationResult = NonNullable< + Awaited> +>; +export type UsersMeChannelsCreateMutationBody = ChannelRequest; +export type UsersMeChannelsCreateMutationError = ErrorType; + +export const useUsersMeChannelsCreate = < + TError = ErrorType, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: ChannelRequest }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { data: ChannelRequest }, + TContext +> => { + const mutationOptions = getUsersMeChannelsCreateMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; +/** + * Manage personal (scope_level=user) integration channels + */ +export type usersMeChannelsRetrieveResponse200 = { + data: Channel; + status: 200; +}; + +export type usersMeChannelsRetrieveResponseSuccess = + usersMeChannelsRetrieveResponse200 & { + headers: Headers; + }; +export type usersMeChannelsRetrieveResponse = + usersMeChannelsRetrieveResponseSuccess; + +export const getUsersMeChannelsRetrieveUrl = (id: string) => { + return `/api/v1.0/users/me/channels/${id}/`; +}; + +export const usersMeChannelsRetrieve = async ( + id: string, + options?: RequestInit, +): Promise => { + return fetchAPI( + getUsersMeChannelsRetrieveUrl(id), + { + ...options, + method: "GET", + }, + ); +}; + +export const getUsersMeChannelsRetrieveQueryKey = (id?: string) => { + return [`/api/v1.0/users/me/channels/${id}/`] as const; +}; + +export const getUsersMeChannelsRetrieveQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + id: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getUsersMeChannelsRetrieveQueryKey(id); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => + usersMeChannelsRetrieve(id, { signal, ...requestOptions }); + + return { + queryKey, + queryFn, + enabled: !!id, + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type UsersMeChannelsRetrieveQueryResult = NonNullable< + Awaited> +>; +export type UsersMeChannelsRetrieveQueryError = ErrorType; + +export function useUsersMeChannelsRetrieve< + TData = Awaited>, + TError = ErrorType, +>( + id: string, + options: { + query: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useUsersMeChannelsRetrieve< + TData = Awaited>, + TError = ErrorType, +>( + id: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useUsersMeChannelsRetrieve< + TData = Awaited>, + TError = ErrorType, +>( + id: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; + +export function useUsersMeChannelsRetrieve< + TData = Awaited>, + TError = ErrorType, +>( + id: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = getUsersMeChannelsRetrieveQueryOptions(id, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey; + + return query; +} + +/** + * Manage personal (scope_level=user) integration channels + */ +export type usersMeChannelsUpdateResponse200 = { + data: Channel; + status: 200; +}; + +export type usersMeChannelsUpdateResponse400 = { + data: void; + status: 400; +}; + +export type usersMeChannelsUpdateResponse403 = { + data: void; + status: 403; +}; + +export type usersMeChannelsUpdateResponse404 = { + data: void; + status: 404; +}; + +export type usersMeChannelsUpdateResponseSuccess = + usersMeChannelsUpdateResponse200 & { + headers: Headers; + }; +export type usersMeChannelsUpdateResponseError = ( + | usersMeChannelsUpdateResponse400 + | usersMeChannelsUpdateResponse403 + | usersMeChannelsUpdateResponse404 +) & { + headers: Headers; +}; + +export type usersMeChannelsUpdateResponse = + | usersMeChannelsUpdateResponseSuccess + | usersMeChannelsUpdateResponseError; + +export const getUsersMeChannelsUpdateUrl = (id: string) => { + return `/api/v1.0/users/me/channels/${id}/`; +}; + +export const usersMeChannelsUpdate = async ( + id: string, + channelRequest: ChannelRequest, + options?: RequestInit, +): Promise => { + return fetchAPI( + getUsersMeChannelsUpdateUrl(id), + { + ...options, + method: "PUT", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(channelRequest), + }, + ); +}; + +export const getUsersMeChannelsUpdateMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: string; data: ChannelRequest }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { id: string; data: ChannelRequest }, + TContext +> => { + const mutationKey = ["usersMeChannelsUpdate"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { id: string; data: ChannelRequest } + > = (props) => { + const { id, data } = props ?? {}; + + return usersMeChannelsUpdate(id, data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type UsersMeChannelsUpdateMutationResult = NonNullable< + Awaited> +>; +export type UsersMeChannelsUpdateMutationBody = ChannelRequest; +export type UsersMeChannelsUpdateMutationError = ErrorType; + +export const useUsersMeChannelsUpdate = < + TError = ErrorType, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: string; data: ChannelRequest }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { id: string; data: ChannelRequest }, + TContext +> => { + const mutationOptions = getUsersMeChannelsUpdateMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; +/** + * Manage personal (scope_level=user) integration channels + */ +export type usersMeChannelsPartialUpdateResponse200 = { + data: Channel; + status: 200; +}; + +export type usersMeChannelsPartialUpdateResponseSuccess = + usersMeChannelsPartialUpdateResponse200 & { + headers: Headers; + }; +export type usersMeChannelsPartialUpdateResponse = + usersMeChannelsPartialUpdateResponseSuccess; + +export const getUsersMeChannelsPartialUpdateUrl = (id: string) => { + return `/api/v1.0/users/me/channels/${id}/`; +}; + +export const usersMeChannelsPartialUpdate = async ( + id: string, + patchedChannelRequest: PatchedChannelRequest, + options?: RequestInit, +): Promise => { + return fetchAPI( + getUsersMeChannelsPartialUpdateUrl(id), + { + ...options, + method: "PATCH", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(patchedChannelRequest), + }, + ); +}; + +export const getUsersMeChannelsPartialUpdateMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: string; data: PatchedChannelRequest }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { id: string; data: PatchedChannelRequest }, + TContext +> => { + const mutationKey = ["usersMeChannelsPartialUpdate"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { id: string; data: PatchedChannelRequest } + > = (props) => { + const { id, data } = props ?? {}; + + return usersMeChannelsPartialUpdate(id, data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type UsersMeChannelsPartialUpdateMutationResult = NonNullable< + Awaited> +>; +export type UsersMeChannelsPartialUpdateMutationBody = PatchedChannelRequest; +export type UsersMeChannelsPartialUpdateMutationError = ErrorType; + +export const useUsersMeChannelsPartialUpdate = < + TError = ErrorType, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: string; data: PatchedChannelRequest }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { id: string; data: PatchedChannelRequest }, + TContext +> => { + const mutationOptions = + getUsersMeChannelsPartialUpdateMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; +/** + * Manage personal (scope_level=user) integration channels + */ +export type usersMeChannelsDestroyResponse204 = { + data: void; + status: 204; +}; + +export type usersMeChannelsDestroyResponse403 = { + data: void; + status: 403; +}; + +export type usersMeChannelsDestroyResponse404 = { + data: void; + status: 404; +}; + +export type usersMeChannelsDestroyResponseSuccess = + usersMeChannelsDestroyResponse204 & { + headers: Headers; + }; +export type usersMeChannelsDestroyResponseError = ( + | usersMeChannelsDestroyResponse403 + | usersMeChannelsDestroyResponse404 +) & { + headers: Headers; +}; + +export type usersMeChannelsDestroyResponse = + | usersMeChannelsDestroyResponseSuccess + | usersMeChannelsDestroyResponseError; + +export const getUsersMeChannelsDestroyUrl = (id: string) => { + return `/api/v1.0/users/me/channels/${id}/`; +}; + +export const usersMeChannelsDestroy = async ( + id: string, + options?: RequestInit, +): Promise => { + return fetchAPI( + getUsersMeChannelsDestroyUrl(id), + { + ...options, + method: "DELETE", + }, + ); +}; + +export const getUsersMeChannelsDestroyMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: string }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { id: string }, + TContext +> => { + const mutationKey = ["usersMeChannelsDestroy"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { id: string } + > = (props) => { + const { id } = props ?? {}; + + return usersMeChannelsDestroy(id, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type UsersMeChannelsDestroyMutationResult = NonNullable< + Awaited> +>; + +export type UsersMeChannelsDestroyMutationError = ErrorType; + +export const useUsersMeChannelsDestroy = < + TError = ErrorType, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: string }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { id: string }, + TContext +> => { + const mutationOptions = getUsersMeChannelsDestroyMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; +/** + * Manage personal (scope_level=user) integration channels + */ +export type usersMeChannelsRegenerateApiKeyCreateResponse200 = { + data: RegeneratedApiKeyResponse; + status: 200; +}; + +export type usersMeChannelsRegenerateApiKeyCreateResponse400 = { + data: void; + status: 400; +}; + +export type usersMeChannelsRegenerateApiKeyCreateResponse403 = { + data: void; + status: 403; +}; + +export type usersMeChannelsRegenerateApiKeyCreateResponse404 = { + data: void; + status: 404; +}; + +export type usersMeChannelsRegenerateApiKeyCreateResponseSuccess = + usersMeChannelsRegenerateApiKeyCreateResponse200 & { + headers: Headers; + }; +export type usersMeChannelsRegenerateApiKeyCreateResponseError = ( + | usersMeChannelsRegenerateApiKeyCreateResponse400 + | usersMeChannelsRegenerateApiKeyCreateResponse403 + | usersMeChannelsRegenerateApiKeyCreateResponse404 +) & { + headers: Headers; +}; + +export type usersMeChannelsRegenerateApiKeyCreateResponse = + | usersMeChannelsRegenerateApiKeyCreateResponseSuccess + | usersMeChannelsRegenerateApiKeyCreateResponseError; + +export const getUsersMeChannelsRegenerateApiKeyCreateUrl = (id: string) => { + return `/api/v1.0/users/me/channels/${id}/regenerate-api-key/`; +}; + +export const usersMeChannelsRegenerateApiKeyCreate = async ( + id: string, + options?: RequestInit, +): Promise => { + return fetchAPI( + getUsersMeChannelsRegenerateApiKeyCreateUrl(id), + { + ...options, + method: "POST", + }, + ); +}; + +export const getUsersMeChannelsRegenerateApiKeyCreateMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: string }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { id: string }, + TContext +> => { + const mutationKey = ["usersMeChannelsRegenerateApiKeyCreate"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { id: string } + > = (props) => { + const { id } = props ?? {}; + + return usersMeChannelsRegenerateApiKeyCreate(id, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type UsersMeChannelsRegenerateApiKeyCreateMutationResult = NonNullable< + Awaited> +>; + +export type UsersMeChannelsRegenerateApiKeyCreateMutationError = + ErrorType; + +export const useUsersMeChannelsRegenerateApiKeyCreate = < + TError = ErrorType, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: string }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { id: string }, + TContext +> => { + const mutationOptions = + getUsersMeChannelsRegenerateApiKeyCreateMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; diff --git a/src/frontend/src/features/api/gen/models/channel.ts b/src/frontend/src/features/api/gen/models/channel.ts index ff0a213d..db316e13 100644 --- a/src/frontend/src/features/api/gen/models/channel.ts +++ b/src/frontend/src/features/api/gen/models/channel.ts @@ -5,6 +5,7 @@ * This is the messages API schema. * OpenAPI spec version: 1.0.0 (v1.0) */ +import type { ScopeLevelEnum } from "./scope_level_enum"; /** * Serialize Channel model. @@ -21,7 +22,8 @@ export interface Channel { * Type of channel * @maxLength 255 */ - type?: string; + type: string; + readonly scope_level: ScopeLevelEnum; /** Channel-specific configuration settings */ settings?: unknown; /** @@ -34,6 +36,13 @@ export interface Channel { * @nullable */ readonly maildomain: string | null; + /** + * primary key for the record as UUID + * @nullable + */ + readonly user: string | null; + /** @nullable */ + readonly last_used_at: string | null; /** date and time at which a record was created */ readonly created_at: string; /** date and time at which a record was last updated */ diff --git a/src/frontend/src/features/api/gen/models/channel_request.ts b/src/frontend/src/features/api/gen/models/channel_request.ts index 2280daf6..f4086d24 100644 --- a/src/frontend/src/features/api/gen/models/channel_request.ts +++ b/src/frontend/src/features/api/gen/models/channel_request.ts @@ -21,7 +21,7 @@ export interface ChannelRequest { * @minLength 1 * @maxLength 255 */ - type?: string; + type: string; /** Channel-specific configuration settings */ settings?: unknown; } diff --git a/src/frontend/src/features/api/gen/models/index.ts b/src/frontend/src/features/api/gen/models/index.ts index 59d91ea3..663f9ec9 100644 --- a/src/frontend/src/features/api/gen/models/index.ts +++ b/src/frontend/src/features/api/gen/models/index.ts @@ -121,10 +121,12 @@ export * from "./patched_thread_event_request_data_one_of_mentions_item"; export * from "./placeholders_retrieve200"; export * from "./read_message_template"; export * from "./read_message_template_metadata"; +export * from "./regenerated_api_key_response"; export * from "./reset_password_error"; export * from "./reset_password_internal_server_error"; export * from "./reset_password_not_found"; export * from "./reset_password_response"; +export * from "./scope_level_enum"; export * from "./send_create400"; export * from "./send_create403"; export * from "./send_create503"; diff --git a/src/frontend/src/features/api/gen/models/message.ts b/src/frontend/src/features/api/gen/models/message.ts index 4edb50a7..4d80b11a 100644 --- a/src/frontend/src/features/api/gen/models/message.ts +++ b/src/frontend/src/features/api/gen/models/message.ts @@ -50,6 +50,8 @@ export interface Message { readonly is_archived: boolean; readonly has_attachments: boolean; /** @nullable */ + readonly mime_id: string | null; + /** @nullable */ readonly signature: MessageSignature; /** Return the STMSG headers of the message. */ readonly stmsg_headers: MessageStmsgHeaders; diff --git a/src/frontend/src/features/api/gen/models/regenerated_api_key_response.ts b/src/frontend/src/features/api/gen/models/regenerated_api_key_response.ts new file mode 100644 index 00000000..a6ff13cd --- /dev/null +++ b/src/frontend/src/features/api/gen/models/regenerated_api_key_response.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +export interface RegeneratedApiKeyResponse { + /** Channel id (also the X-Channel-Id header value). */ + id: string; + /** Freshly generated plaintext api_key. Returned ONCE on regeneration and cannot be retrieved later. */ + api_key: string; +} diff --git a/src/frontend/src/features/api/gen/models/scope_level_enum.ts b/src/frontend/src/features/api/gen/models/scope_level_enum.ts new file mode 100644 index 00000000..a26824df --- /dev/null +++ b/src/frontend/src/features/api/gen/models/scope_level_enum.ts @@ -0,0 +1,24 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +/** + * * `global` - Global + * `maildomain` - Maildomain + * `mailbox` - Mailbox + * `user` - User + */ +export type ScopeLevelEnum = + (typeof ScopeLevelEnum)[keyof typeof ScopeLevelEnum]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const ScopeLevelEnum = { + global: "global", + maildomain: "maildomain", + mailbox: "mailbox", + user: "user", +} as const;