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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,27 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm

Changes prior to v1.0.0 are available in the [git history](https://github.com/AvaCodeSolutions/django-email-learning/commits/master).

## [Unreleased]

### Added

- **Organization API keys and a new organization-scoped API** — Organization admins can now issue API keys for their own organization via `POST /api/platform/organizations/<id>/api-keys/`, and use them against a new `/api/v1/` surface. v1 covers one endpoint, `POST /api/v1/enrollments/`, which enrolls an email address in one of the organization's courses. Keys carry explicit scopes — `enrollments:create` is the only one for now, and an organization key must carry at least one — plus an optional expiry. The organization is taken from the key itself rather than from the URL or request body, so a key can only ever act on the organization it was issued for; a slug or id belonging to another organization reads as `404`. Only organization *admins* can issue keys, since a key acts with whatever scopes it carries. Requests are rate limited per key (defaults 120/60s, configurable via `DJANGO_EMAIL_LEARNING["ORGANIZATION_API_RATE_LIMITS"]`). This is separate from the existing unauthenticated `/api/public/` embed surface. Keys are managed from a new **API Keys** tab on the organization page, which lists each key's id, scopes, status and last use, and shows the key itself only once, at creation. See the new [Organization API](https://django-email-learning.readthedocs.io/en/latest/technical/organization-api.html) reference.
- **`ORGANIZATION_API` platform feature and permission hooks for key management** — A new `PlatformFeature.ORGANIZATION_API`, present by default, controls whether the organization page shows its API Keys tab; remove it from `get_available_features()` to hide it. The API side has its own control: override `can_create_organization_api_key(request, organization)` on `OrganizationApiKeyView` or `can_delete_organization_api_key(request, organization)` on `SingleOrganizationApiKeyView`. Both default to `True` and reject with `403` before any database work when they return `False`, following the same pattern as the existing `can_create_course` hook. They receive the resolved `Organization` rather than its id, so a check can read its state without a second query.
- **OpenAPI 3.1 schema for the organization API** — `GET /api/v1/openapi.json` serves a machine-readable description of the v1 API for Swagger UI, Redoc or a client generator. It takes no API key, since it describes the API's shape and carries no organization data; set `DJANGO_EMAIL_LEARNING["ORGANIZATION_API_DOCS_ENABLED"] = False` to stop serving it. The document is generated from the running code rather than maintained separately — paths from the URLconf, schemas from the Pydantic models the views validate with, and security requirements from the scopes the auth decorator enforces — and a test fails the build if a routed endpoint has no documentation. No new dependency.
- **API keys now support naming, expiry, revocation and last-used tracking** — Both platform and organization keys take a `name` and an optional `expires_at`, record `last_used_at` on each authenticated request (at minute resolution, so recording activity doesn't cost a write per request), and can be revoked.

### Security

- **API keys are stored hashed instead of reversibly encrypted** (breaking) — Keys were held as Fernet ciphertext under `ENCRYPTION_SECRET_KEY`, which meant anyone with the database and that setting could recover every key in plaintext; the listing endpoint and the settings UI both did exactly that on every page load. Keys are now stored as a SHA-256 hash of a 256-bit random secret and the full token is returned **once**, at creation. The token format is `elk_<key_id>_<secret>`, where `key_id` is a non-secret public identifier that makes verification a single indexed lookup instead of decrypting every candidate row. **Existing keys keep working** — a data migration derives the new hash from the stored ciphertext, and the pre-3.1.0 JWT resolves through the same lookup — but they can no longer be *read back*: the API Keys page now shows each key's `key_id` and status rather than the key itself, and an operator who has lost a key must issue a replacement. Support for the legacy JWT format will be removed in a future release.
- **The JWT wrapper around API keys is no longer issued** (breaking) — Keys were handed out as a JWT signed with `JWT_SECRET_KEY` and a fixed `exp` of `datetime.max`. It never expired and carried no claim that wasn't already the credential; its only real function was smuggling the row's `salt` to narrow the old decrypt-and-compare lookup, which the new `key_id` does directly. New keys are issued as the bare token. Existing JWTs are still accepted.
- **Organization keys cannot reach platform endpoints, and vice versa** — The two kinds are distinguished by an explicit `key_type` column with a database check constraint tying it to the presence of an organization, rather than by inferring "platform" from the organization being null. A dropped filter therefore cannot silently produce a key with deployment-wide authority; `check_api_key` asserts the platform type positively, and the job-trigger endpoints return `403` for an organization key.

### Changed

- **`POST /api/platform/api_keys/` returns `token` instead of `key`** (breaking) — The creation response now carries the full token as `token`, alongside metadata including `key_id`, `name`, `key_type`, `scopes`, `expires_at`, `revoked_at` and `last_used_at`. `GET /api/platform/api_keys/` returns that same metadata and **never** returns a usable credential; it is also now filtered to platform keys only. Anything reading `key` from either response needs updating.
- **`DELETE /api/platform/api_keys/<id>/` revokes rather than deletes** (breaking) — The row is retained with `revoked_at` set, so the audit trail of which keys existed and when each was last used survives. The response message changed from `"API Key deleted successfully"` to `"API Key revoked successfully"`. Revoked keys fail authentication with `401`.
- **`rotate_encryption_key` no longer processes API keys** — Hashes cannot be re-encrypted and do not need rotating. The command still rotates `ImapConnection.password`. Rotating `ENCRYPTION_SECRET_KEY` no longer invalidates API keys.

## [3.0.0] - 2026-08-05

> **A deliberately small major.** Nothing here requires a migration on the scale of 2.0.0 — for most projects the upgrade is a no-op. The major bump reflects that two changes alter behavior existing callers can observe, not that the release is large. Read the two entries below if you query organizations by name or render the `Organization` model directly.
Expand Down
94 changes: 64 additions & 30 deletions django_email_learning/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
from django.http import JsonResponse

from django_email_learning.apps import PLATFORM_ADMIN_GROUP_NAME
from django_email_learning.models import ApiKey, OrganizationUser
from django_email_learning.services.jwt_service import (
ExpiredTokenException,
InvalidTokenException,
decode_jwt,
from django_email_learning.models import ApiKeyType, OrganizationUser
from django_email_learning.services.api_key_service import (
ApiKeyAuthenticationError,
authenticate_token,
extract_bearer_token,
)


Expand Down Expand Up @@ -139,39 +139,73 @@ def _wrapped_view(request, *view_args, **view_kwargs) -> JsonResponse: # type:


def check_api_key() -> typing.Callable:
"""Authenticates a *platform* API key.

The key type is asserted positively rather than inferred from the key
having no organization: these endpoints act deployment-wide, so an
organization key must never reach them by default.
"""

def decorator(view_func: typing.Callable) -> typing.Callable:
@wraps(view_func)
def _wrapped_view(request, *view_args, **view_kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
try:
api_key = authenticate_token(extract_bearer_token(request))
except ApiKeyAuthenticationError as e:
return JsonResponse({"error": e.message}, status=e.status)

if api_key.key_type != ApiKeyType.PLATFORM:
return JsonResponse({"error": "Forbidden"}, status=403)

request.api_key = api_key
return view_func(request, *view_args, **view_kwargs)

return _wrapped_view

return decorator


def require_organization_api_key(scopes: typing.Iterable[str] = ()) -> typing.Callable:
"""Authenticates an *organization* API key carrying all of `scopes`.

Binds `request.api_key` and `request.organization`. The organization is
taken from the key itself; a view must read it from there rather than from
the URL, or a caller could act on an organization its key doesn't cover.
Where a URL does name an organization it has to agree with the key.
"""
required_scopes = set(scopes)

def decorator(view_func: typing.Callable) -> typing.Callable:
@wraps(view_func)
def _wrapped_view(request, *view_args, **view_kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
authorization_header = request.headers.get("Authorization")
if not authorization_header:
return JsonResponse({"error": "Authorization header missing"}, status=401)
authorization_header_parts = authorization_header.split(" ")
if len(authorization_header_parts) != 2 or authorization_header_parts[0] != "Bearer":
try:
api_key = authenticate_token(extract_bearer_token(request))
except ApiKeyAuthenticationError as e:
return JsonResponse({"error": e.message}, status=e.status)

if api_key.key_type != ApiKeyType.ORGANIZATION:
return JsonResponse({"error": "Forbidden"}, status=403)

missing_scopes = required_scopes - set(api_key.scopes)
if missing_scopes:
return JsonResponse(
{"error": "Invalid Authorization header format. Expected: Bearer <API_KEY>"},
status=401,
{"error": f"API key is missing required scope(s): {', '.join(sorted(missing_scopes))}"},
status=403,
)
api_key = authorization_header_parts[1]
try:
key_data = decode_jwt(api_key)
possible_keys = ApiKey.objects.filter(salt=key_data["salt"])
key_matched = False
for possible_key in possible_keys:
key_value = possible_key.decrypt_password(possible_key.key)
if key_value == key_data["key"]:
key_matched = True
break
if not key_matched:
return JsonResponse({"error": "Invalid API key"}, status=401)
except ExpiredTokenException:
return JsonResponse({"error": "Expired Json Web Token"}, status=401)
except InvalidTokenException:
return JsonResponse({"error": "Invalid Json Web Token"}, status=401)
except KeyError:
return JsonResponse({"error": "Json Web Token missing required fields"}, status=401)

# 404 rather than 403 for a mismatch: a key holder shouldn't be able
# to probe which other organization ids exist.
if "organization_id" in view_kwargs and view_kwargs["organization_id"] != api_key.organization_id:
return JsonResponse({"error": "Not found"}, status=404)

request.api_key = api_key
request.organization = api_key.organization
return view_func(request, *view_args, **view_kwargs)

# Published so the OpenAPI generator can read the scopes off the view
# rather than being told them a second time. The documented security
# requirement is then the enforced one by construction, and can't drift.
_wrapped_view.required_api_key_scopes = frozenset(required_scopes) # type: ignore[attr-defined]
return _wrapped_view

return decorator
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from django.core.management.base import BaseCommand, CommandParser, OutputWrapper
from django.db import transaction

from django_email_learning.models import ApiKey, ImapConnection
from django_email_learning.models import ImapConnection
from django_email_learning.models.mixin_models import EncryptionMixin

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -96,10 +96,11 @@ def handle(self, *args, **options) -> None: # type: ignore[no-untyped-def]
if dry_run:
self.stdout.write(self.style.WARNING("Running in DRY-RUN mode — no changes will be written."))

# Models and their encrypted field
# Models and their encrypted field. ApiKey used to appear here; it now
# stores only a hash of its secret, which by definition cannot be
# re-encrypted under a new key and needs no rotation.
targets: list[tuple[type[EncryptionMixin], str]] = [
(ImapConnection, "password"),
(ApiKey, "key"),
]

total = 0
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):
"""Adds the new ApiKey columns as nullable so 0018 can backfill them.

Split across three migrations because the backfill in 0018 has to read the
old encrypted `key` column, which 0019 then drops.
"""

dependencies = [
("django_email_learning", "0016_alter_organization_name"),
]

operations = [
migrations.AddField(
model_name="apikey",
name="key_type",
field=models.CharField(
choices=[("platform", "Platform"), ("organization", "Organization")],
db_index=True,
max_length=20,
null=True,
),
),
migrations.AddField(
model_name="apikey",
name="organization",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="api_keys",
to="django_email_learning.organization",
),
),
migrations.AddField(
model_name="apikey",
name="name",
field=models.CharField(max_length=100, null=True),
),
migrations.AddField(
model_name="apikey",
name="key_id",
field=models.CharField(editable=False, max_length=32, null=True),
),
migrations.AddField(
model_name="apikey",
name="secret_hash",
field=models.CharField(editable=False, max_length=64, null=True),
),
migrations.AddField(
model_name="apikey",
name="scopes",
field=models.JSONField(blank=True, default=list),
),
migrations.AddField(
model_name="apikey",
name="expires_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="apikey",
name="revoked_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="apikey",
name="last_used_at",
field=models.DateTimeField(blank=True, null=True),
),
]
77 changes: 77 additions & 0 deletions django_email_learning/migrations/0018_backfill_api_key_hashes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import base64
import hashlib
import secrets
import sys

from cryptography.fernet import Fernet, InvalidToken
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from django.conf import settings
from django.db import migrations


def _fernet(salt: str) -> Fernet:
"""Reimplements EncryptionMixin._fernet as it stood before this release.

Inlined rather than imported: the model method is removed by 0019, and a
data migration must keep working against the code of its own era rather
than whatever the model looks like today.
"""
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=salt.encode(),
iterations=100000,
)
secret = str(settings.DJANGO_EMAIL_LEARNING["ENCRYPTION_SECRET_KEY"])
return Fernet(base64.urlsafe_b64encode(kdf.derive(secret.encode())))


def backfill(apps, schema_editor) -> None: # type: ignore[no-untyped-def]
"""Derives key_id/secret_hash for existing keys from the encrypted column.

Existing credentials keep working: the pre-3.1 JWT carries the raw key, and
the new authentication path hashes whatever it extracts from that JWT into
the same `secret_hash` written here.

A key whose ciphertext won't decrypt — the usual cause is a rotated
ENCRYPTION_SECRET_KEY — gets a random hash no token can ever match, so the
row survives the non-null constraint in 0019 while failing closed. The
alternative, deleting it, would silently widen access if that key was the
only thing gating an endpoint.
"""
ApiKey = apps.get_model("django_email_learning", "ApiKey")
undecryptable = []

for api_key in ApiKey.objects.all():
try:
plaintext = _fernet(api_key.salt).decrypt(api_key.key.encode()).decode()
secret_hash = hashlib.sha256(plaintext.encode()).hexdigest()
except (InvalidToken, ValueError, TypeError):
undecryptable.append(api_key.pk)
secret_hash = hashlib.sha256(secrets.token_urlsafe(32).encode()).hexdigest()

api_key.key_type = "platform"
api_key.key_id = secrets.token_hex(12)
api_key.secret_hash = secret_hash
api_key.name = f"Legacy platform key #{api_key.pk}"
api_key.scopes = []
api_key.save(update_fields=["key_type", "key_id", "secret_hash", "name", "scopes"])

if undecryptable:
print(
f"\n WARNING: {len(undecryptable)} API key(s) could not be decrypted "
f"(ids: {', '.join(str(pk) for pk in undecryptable)}). They have been "
f"left in place but can no longer authenticate; issue replacements.",
file=sys.stderr,
)


class Migration(migrations.Migration):
dependencies = [
("django_email_learning", "0017_apikey_add_type_and_hashed_storage"),
]

# Irreversible in substance rather than in form: 0019 drops the plaintext
# source, and a hash cannot be turned back into the key it came from.
operations = [migrations.RunPython(backfill, migrations.RunPython.noop)]
Loading
Loading