Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/messages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 0 additions & 3 deletions env.d/development/backend.defaults
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,6 @@ LOGGING_LEVEL_LOGGERS_APP=INFO
ENABLE_PROMETHEUS=0
PROMETHEUS_API_KEY=ExamplePrometheusApiKey

# Metrics
METRICS_API_KEY=ExampleMetricsApiKey

# Python
PYTHONPATH=/app

Expand Down
130 changes: 121 additions & 9 deletions src/backend/core/admin.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -29,6 +31,33 @@
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."""

Expand Down Expand Up @@ -383,27 +412,110 @@ def throttle_status_display(self, obj):
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(
"<path:object_id>/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):
Expand Down
85 changes: 85 additions & 0 deletions src/backend/core/api/authentication.py
Original file line number Diff line number Diff line change
@@ -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: <uuid> (public, identifies which channel)
X-API-Key: <raw secret> (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()
Comment thread
sylvinus marked this conversation as resolved.
Dismissed
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"
Loading
Loading