Skip to content

Commit fa56223

Browse files
feat: API to replace email alias generation commands (ietf-tools#7012)
* feat: DraftAliasGenerator class Encapsulates logic from generate_draft_aliases.py * refactor: Avoid circular imports * feat: Add draft_aliases API endpoint * feat: Add @requires_api_token decorator Stolen from feat/rpc-api * feat: Add token auth to draft_aliases endpoint * feat: draft-aliases-from-json.py script Parses output from the draft_aliases API call * chore: Remove unused cruft * refactor: Avoid shadowing "draft" name * fix: Suppress empty lists from DraftAliasGenerator * refactor: Use a GET instead of POST * feat: GroupAliasGenerator class * feat: group aliases API view * fix: Handle domains array correctly * fix: Suppress empty group aliases * refactor: Generalize aliases-from-json.py script * refactor: Same output fmt for draft and group alias apis * feat: Sort addresses for stability * fix: Add "anything" virtual alias * test: Test requires_api_token decorator * feat: Harden is_valid_token against misconfig * test: Test is_valid_token * test: Test draft_aliases view * test: Test group_aliases view * test: Test DraftAliasGenerator * fix: ise group is type "ise" in test data * test: Fix logic in testManagementCommand The test was incorrect - and fails when fixed. :-( * test: Test GroupAliasGenerator Test currently fails * fix: Suppress empty -ads alias * test: Fix group acronym copy/paste error I *think* this must be what had been intended. The code does not look like it ever dealt with GroupHistory, so I'm pretty sure it wasn't meant to have the same acronym used by two different Groups at different times. * test: Check draft .notify alias generation * test: Cover get_draft_notify_emails()
1 parent ae01f6f commit fa56223

10 files changed

Lines changed: 771 additions & 24 deletions

File tree

ietf/api/ietf_utils.py

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,75 @@
22

33
# This is not utils.py because Tastypie implicitly consumes ietf.api.utils.
44
# See ietf.api.__init__.py for details.
5+
from functools import wraps
6+
from typing import Callable, Optional, Union
57

68
from django.conf import settings
9+
from django.http import HttpResponseForbidden
10+
711

812
def is_valid_token(endpoint, token):
913
# This is where we would consider integration with vault
1014
# Settings implementation for now.
1115
if hasattr(settings, "APP_API_TOKENS"):
1216
token_store = settings.APP_API_TOKENS
13-
if endpoint in token_store and token in token_store[endpoint]:
14-
return True
17+
if endpoint in token_store:
18+
endpoint_tokens = token_store[endpoint]
19+
# Be sure endpoints is a list or tuple so we don't accidentally use substring matching!
20+
if not isinstance(endpoint_tokens, (list, tuple)):
21+
endpoint_tokens = [endpoint_tokens]
22+
if token in endpoint_tokens:
23+
return True
1524
return False
25+
26+
27+
def requires_api_token(func_or_endpoint: Optional[Union[Callable, str]] = None):
28+
"""Validate API token before executing the wrapped method
29+
30+
Usage:
31+
* Basic: endpoint defaults to the qualified name of the wrapped method. E.g., in ietf.api.views,
32+
33+
@requires_api_token
34+
def my_view(request):
35+
...
36+
37+
will require a token for "ietf.api.views.my_view"
38+
39+
* Custom endpoint: specify the endpoint explicitly
40+
41+
@requires_api_token("ietf.api.views.some_other_thing")
42+
def my_view(request):
43+
...
44+
45+
will require a token for "ietf.api.views.some_other_thing"
46+
"""
47+
48+
def decorate(f):
49+
if _endpoint is None:
50+
fname = getattr(f, "__qualname__", None)
51+
if fname is None:
52+
raise TypeError(
53+
"Cannot automatically decorate function that does not support __qualname__. "
54+
"Explicitly set the endpoint."
55+
)
56+
endpoint = "{}.{}".format(f.__module__, fname)
57+
else:
58+
endpoint = _endpoint
59+
60+
@wraps(f)
61+
def wrapped(request, *args, **kwargs):
62+
authtoken = request.META.get("HTTP_X_API_KEY", None)
63+
if authtoken is None or not is_valid_token(endpoint, authtoken):
64+
return HttpResponseForbidden()
65+
return f(request, *args, **kwargs)
66+
67+
return wrapped
68+
69+
# Magic to allow decorator to be used with or without parentheses
70+
if callable(func_or_endpoint):
71+
func = func_or_endpoint
72+
_endpoint = None
73+
return decorate(func)
74+
else:
75+
_endpoint = func_or_endpoint
76+
return decorate

ietf/api/tests.py

Lines changed: 154 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import datetime
55
import json
66
import html
7+
import mock
78
import os
89
import sys
910

@@ -12,7 +13,8 @@
1213

1314
from django.apps import apps
1415
from django.conf import settings
15-
from django.test import Client
16+
from django.http import HttpResponseForbidden
17+
from django.test import Client, RequestFactory
1618
from django.test.utils import override_settings
1719
from django.urls import reverse as urlreverse
1820
from django.utils import timezone
@@ -38,6 +40,8 @@
3840
from ietf.utils.models import DumpInfo
3941
from ietf.utils.test_utils import TestCase, login_testing_unauthorized, reload_db_objects
4042

43+
from .ietf_utils import is_valid_token, requires_api_token
44+
4145
OMITTED_APPS = (
4246
'ietf.secr.meetings',
4347
'ietf.secr.proceedings',
@@ -780,7 +784,74 @@ def test_api_get_session_matherials_no_agenda_meeting_url(self):
780784
url = urlreverse('ietf.meeting.views.api_get_session_materials', kwargs={'session_id': session.pk})
781785
r = self.client.get(url)
782786
self.assertEqual(r.status_code, 200)
787+
788+
@override_settings(APP_API_TOKENS={"ietf.api.views.email_aliases": ["valid-token"]})
789+
@mock.patch("ietf.api.views.DraftAliasGenerator")
790+
def test_draft_aliases(self, mock):
791+
mock.return_value = (("alias1", ("a1", "a2")), ("alias2", ("a3", "a4")))
792+
url = urlreverse("ietf.api.views.draft_aliases")
793+
r = self.client.get(url, headers={"X-Api-Key": "valid-token"})
794+
self.assertEqual(r.status_code, 200)
795+
self.assertEqual(r.headers["Content-type"], "application/json")
796+
self.assertEqual(
797+
json.loads(r.content),
798+
{
799+
"aliases": [
800+
{"alias": "alias1", "domains": ["ietf"], "addresses": ["a1", "a2"]},
801+
{"alias": "alias2", "domains": ["ietf"], "addresses": ["a3", "a4"]},
802+
]}
803+
)
804+
# some invalid cases
805+
self.assertEqual(
806+
self.client.get(url, headers={}).status_code,
807+
403,
808+
)
809+
self.assertEqual(
810+
self.client.get(url, headers={"X-Api-Key": "something-else"}).status_code,
811+
403,
812+
)
813+
self.assertEqual(
814+
self.client.post(url, headers={"X-Api-Key": "something-else"}).status_code,
815+
403,
816+
)
817+
self.assertEqual(
818+
self.client.post(url, headers={"X-Api-Key": "valid-token"}).status_code,
819+
405,
820+
)
783821

822+
@override_settings(APP_API_TOKENS={"ietf.api.views.email_aliases": ["valid-token"]})
823+
@mock.patch("ietf.api.views.GroupAliasGenerator")
824+
def test_group_aliases(self, mock):
825+
mock.return_value = (("alias1", ("ietf",), ("a1", "a2")), ("alias2", ("ietf", "iab"), ("a3", "a4")))
826+
url = urlreverse("ietf.api.views.group_aliases")
827+
r = self.client.get(url, headers={"X-Api-Key": "valid-token"})
828+
self.assertEqual(r.status_code, 200)
829+
self.assertEqual(r.headers["Content-type"], "application/json")
830+
self.assertEqual(
831+
json.loads(r.content),
832+
{
833+
"aliases": [
834+
{"alias": "alias1", "domains": ["ietf"], "addresses": ["a1", "a2"]},
835+
{"alias": "alias2", "domains": ["ietf", "iab"], "addresses": ["a3", "a4"]},
836+
]}
837+
)
838+
# some invalid cases
839+
self.assertEqual(
840+
self.client.get(url, headers={}).status_code,
841+
403,
842+
)
843+
self.assertEqual(
844+
self.client.get(url, headers={"X-Api-Key": "something-else"}).status_code,
845+
403,
846+
)
847+
self.assertEqual(
848+
self.client.post(url, headers={"X-Api-Key": "something-else"}).status_code,
849+
403,
850+
)
851+
self.assertEqual(
852+
self.client.post(url, headers={"X-Api-Key": "valid-token"}).status_code,
853+
405,
854+
)
784855

785856

786857
class DirectAuthApiTests(TestCase):
@@ -1133,3 +1204,85 @@ def test_no_such_document(self):
11331204
url = urlreverse(self.target_view, kwargs={'name': name})
11341205
r = self.client.get(url)
11351206
self.assertEqual(r.status_code, 404)
1207+
1208+
1209+
class TokenTests(TestCase):
1210+
@override_settings(APP_API_TOKENS={"known.endpoint": ["token in a list"], "oops": "token as a str"})
1211+
def test_is_valid_token(self):
1212+
# various invalid cases
1213+
self.assertFalse(is_valid_token("unknown.endpoint", "token in a list"))
1214+
self.assertFalse(is_valid_token("known.endpoint", "token"))
1215+
self.assertFalse(is_valid_token("known.endpoint", "token as a str"))
1216+
self.assertFalse(is_valid_token("oops", "token"))
1217+
self.assertFalse(is_valid_token("oops", "token in a list"))
1218+
# the only valid cases
1219+
self.assertTrue(is_valid_token("known.endpoint", "token in a list"))
1220+
self.assertTrue(is_valid_token("oops", "token as a str"))
1221+
1222+
@mock.patch("ietf.api.ietf_utils.is_valid_token")
1223+
def test_requires_api_token(self, mock_is_valid_token):
1224+
called = False
1225+
1226+
@requires_api_token
1227+
def fn_to_wrap(request, *args, **kwargs):
1228+
nonlocal called
1229+
called = True
1230+
return request, args, kwargs
1231+
1232+
req_factory = RequestFactory()
1233+
arg = object()
1234+
kwarg = object()
1235+
1236+
# No X-Api-Key header
1237+
mock_is_valid_token.return_value = False
1238+
val = fn_to_wrap(
1239+
req_factory.get("/some/url", headers={}),
1240+
arg,
1241+
kwarg=kwarg,
1242+
)
1243+
self.assertTrue(isinstance(val, HttpResponseForbidden))
1244+
self.assertFalse(mock_is_valid_token.called)
1245+
self.assertFalse(called)
1246+
1247+
# Bad X-Api-Key header (not resetting the mock, it was not used yet)
1248+
val = fn_to_wrap(
1249+
req_factory.get("/some/url", headers={"X-Api-Key": "some-value"}),
1250+
arg,
1251+
kwarg=kwarg,
1252+
)
1253+
self.assertTrue(isinstance(val, HttpResponseForbidden))
1254+
self.assertTrue(mock_is_valid_token.called)
1255+
self.assertEqual(
1256+
mock_is_valid_token.call_args[0],
1257+
(fn_to_wrap.__module__ + "." + fn_to_wrap.__qualname__, "some-value"),
1258+
)
1259+
self.assertFalse(called)
1260+
1261+
# Valid header
1262+
mock_is_valid_token.reset_mock()
1263+
mock_is_valid_token.return_value = True
1264+
request = req_factory.get("/some/url", headers={"X-Api-Key": "some-value"})
1265+
# Bad X-Api-Key header (not resetting the mock, it was not used yet)
1266+
val = fn_to_wrap(
1267+
request,
1268+
arg,
1269+
kwarg=kwarg,
1270+
)
1271+
self.assertEqual(val, (request, (arg,), {"kwarg": kwarg}))
1272+
self.assertTrue(mock_is_valid_token.called)
1273+
self.assertEqual(
1274+
mock_is_valid_token.call_args[0],
1275+
(fn_to_wrap.__module__ + "." + fn_to_wrap.__qualname__, "some-value"),
1276+
)
1277+
self.assertTrue(called)
1278+
1279+
# Test the endpoint setting
1280+
@requires_api_token("endpoint")
1281+
def another_fn_to_wrap(request):
1282+
return "yep"
1283+
1284+
val = another_fn_to_wrap(request)
1285+
self.assertEqual(
1286+
mock_is_valid_token.call_args[0],
1287+
("endpoint", "some-value"),
1288+
)

ietf/api/urls.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,12 @@
2222
url(r'^v2/person/person', api_views.ApiV2PersonExportView.as_view()),
2323
#
2424
# --- Custom API endpoints, sorted alphabetically ---
25+
# Email alias information for drafts
26+
url(r'^doc/draft-aliases/$', api_views.draft_aliases),
2527
# GPRD: export of personal information for the logged-in person
2628
url(r'^export/personal-information/$', api_views.PersonalInformationExportView.as_view()),
29+
# Email alias information for groups
30+
url(r'^group/group-aliases/$', api_views.group_aliases),
2731
# Let IESG members set positions programmatically
2832
url(r'^iesg/position', views_ballot.api_set_position),
2933
# Let Meetecho set session video URLs

ietf/api/views.py

Lines changed: 48 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,42 +2,39 @@
22
# -*- coding: utf-8 -*-
33

44
import json
5-
import pytz
65
import re
76

8-
from jwcrypto.jwk import JWK
9-
7+
import pytz
108
from django.conf import settings
119
from django.contrib.auth import authenticate
1210
from django.contrib.auth.decorators import login_required
1311
from django.contrib.auth.models import User
1412
from django.core.exceptions import ValidationError
1513
from django.core.validators import validate_email
16-
from django.http import HttpResponse, Http404
14+
from django.http import HttpResponse, Http404, JsonResponse
1715
from django.shortcuts import render, get_object_or_404
1816
from django.urls import reverse
1917
from django.utils.decorators import method_decorator
2018
from django.views.decorators.csrf import csrf_exempt
2119
from django.views.decorators.gzip import gzip_page
2220
from django.views.generic.detail import DetailView
23-
21+
from jwcrypto.jwk import JWK
2422
from tastypie.exceptions import BadRequest
25-
from tastypie.utils.mime import determine_format, build_content_type
26-
from tastypie.utils import is_valid_jsonp_callback_value
2723
from tastypie.serializers import Serializer
28-
29-
import debug # pyflakes:ignore
24+
from tastypie.utils import is_valid_jsonp_callback_value
25+
from tastypie.utils.mime import determine_format, build_content_type
3026

3127
import ietf
32-
from ietf.person.models import Person, Email
3328
from ietf.api import _api_list
29+
from ietf.api.ietf_utils import is_valid_token, requires_api_token
3430
from ietf.api.serializer import JsonExportMixin
35-
from ietf.api.ietf_utils import is_valid_token
36-
from ietf.doc.utils import fuzzy_find_documents
37-
from ietf.ietfauth.views import send_account_creation_email
31+
from ietf.doc.utils import DraftAliasGenerator, fuzzy_find_documents
32+
from ietf.group.utils import GroupAliasGenerator
3833
from ietf.ietfauth.utils import role_required
34+
from ietf.ietfauth.views import send_account_creation_email
3935
from ietf.meeting.models import Meeting
4036
from ietf.nomcom.models import Volunteer, NomCom
37+
from ietf.person.models import Person, Email
4138
from ietf.stats.models import MeetingRegistration
4239
from ietf.utils import log
4340
from ietf.utils.decorators import require_api_key
@@ -453,3 +450,41 @@ def directauth(request):
453450

454451
else:
455452
return HttpResponse(status=405)
453+
454+
455+
@requires_api_token("ietf.api.views.email_aliases")
456+
@csrf_exempt
457+
def draft_aliases(request):
458+
if request.method == "GET":
459+
return JsonResponse(
460+
{
461+
"aliases": [
462+
{
463+
"alias": alias,
464+
"domains": ["ietf"],
465+
"addresses": address_list,
466+
}
467+
for alias, address_list in DraftAliasGenerator()
468+
]
469+
}
470+
)
471+
return HttpResponse(status=405)
472+
473+
474+
@requires_api_token("ietf.api.views.email_aliases")
475+
@csrf_exempt
476+
def group_aliases(request):
477+
if request.method == "GET":
478+
return JsonResponse(
479+
{
480+
"aliases": [
481+
{
482+
"alias": alias,
483+
"domains": domains,
484+
"addresses": address_list,
485+
}
486+
for alias, domains, address_list in GroupAliasGenerator()
487+
]
488+
}
489+
)
490+
return HttpResponse(status=405)

0 commit comments

Comments
 (0)