Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
bf81e33
feat: DraftAliasGenerator class
jennifer-richards Jan 30, 2024
c6ed428
refactor: Avoid circular imports
jennifer-richards Jan 30, 2024
e4a902a
feat: Add draft_aliases API endpoint
jennifer-richards Jan 30, 2024
8d0a533
feat: Add @requires_api_token decorator
jennifer-richards Jan 30, 2024
d61ce80
feat: Add token auth to draft_aliases endpoint
jennifer-richards Jan 30, 2024
83dc67c
feat: draft-aliases-from-json.py script
jennifer-richards Jan 31, 2024
965d97e
chore: Remove unused cruft
jennifer-richards Jan 31, 2024
d380867
refactor: Avoid shadowing "draft" name
jennifer-richards Jan 31, 2024
37df13e
fix: Suppress empty lists from DraftAliasGenerator
jennifer-richards Jan 31, 2024
e5197b9
refactor: Use a GET instead of POST
jennifer-richards Jan 31, 2024
e30ed3d
feat: GroupAliasGenerator class
jennifer-richards Feb 1, 2024
cc1cb55
feat: group aliases API view
jennifer-richards Feb 1, 2024
f299308
fix: Handle domains array correctly
jennifer-richards Feb 1, 2024
110ebf6
fix: Suppress empty group aliases
jennifer-richards Feb 1, 2024
06d73eb
refactor: Generalize aliases-from-json.py script
jennifer-richards Feb 1, 2024
28dcdf7
refactor: Same output fmt for draft and group alias apis
jennifer-richards Feb 1, 2024
2b08802
feat: Sort addresses for stability
jennifer-richards Feb 1, 2024
24b929a
fix: Add "anything" virtual alias
jennifer-richards Feb 1, 2024
811c6b8
Merge branch 'main' into celery-cronch-runner
jennifer-richards Feb 6, 2024
510fb32
test: Test requires_api_token decorator
jennifer-richards Feb 6, 2024
3862a15
feat: Harden is_valid_token against misconfig
jennifer-richards Feb 6, 2024
25bd4b1
test: Test is_valid_token
jennifer-richards Feb 6, 2024
bbaec7d
test: Test draft_aliases view
jennifer-richards Feb 6, 2024
cec54d5
test: Test group_aliases view
jennifer-richards Feb 6, 2024
2d2353c
test: Test DraftAliasGenerator
jennifer-richards Feb 6, 2024
69be6a0
fix: ise group is type "ise" in test data
jennifer-richards Feb 6, 2024
c315f31
test: Fix logic in testManagementCommand
jennifer-richards Feb 7, 2024
ede43c3
test: Test GroupAliasGenerator
jennifer-richards Feb 7, 2024
405d4e0
fix: Suppress empty -ads alias
jennifer-richards Feb 7, 2024
aa1edd4
test: Fix group acronym copy/paste error
jennifer-richards Feb 7, 2024
4a80e30
test: Check draft .notify alias generation
jennifer-richards Feb 7, 2024
ceb52a1
test: Cover get_draft_notify_emails()
jennifer-richards Feb 7, 2024
b715d72
Merge branch 'main' into celery-cronch-runner
jennifer-richards Feb 7, 2024
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
65 changes: 63 additions & 2 deletions ietf/api/ietf_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,75 @@

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

from django.conf import settings
from django.http import HttpResponseForbidden


def is_valid_token(endpoint, token):
# This is where we would consider integration with vault
# Settings implementation for now.
if hasattr(settings, "APP_API_TOKENS"):
token_store = settings.APP_API_TOKENS
if endpoint in token_store and token in token_store[endpoint]:
return True
if endpoint in token_store:
endpoint_tokens = token_store[endpoint]
# Be sure endpoints is a list or tuple so we don't accidentally use substring matching!
if not isinstance(endpoint_tokens, (list, tuple)):
endpoint_tokens = [endpoint_tokens]
if token in endpoint_tokens:
return True
return False


def requires_api_token(func_or_endpoint: Optional[Union[Callable, str]] = None):
"""Validate API token before executing the wrapped method

Usage:
* Basic: endpoint defaults to the qualified name of the wrapped method. E.g., in ietf.api.views,

@requires_api_token
def my_view(request):
...

will require a token for "ietf.api.views.my_view"

* Custom endpoint: specify the endpoint explicitly

@requires_api_token("ietf.api.views.some_other_thing")
def my_view(request):
...

will require a token for "ietf.api.views.some_other_thing"
"""

def decorate(f):
if _endpoint is None:
fname = getattr(f, "__qualname__", None)
if fname is None:
raise TypeError(
"Cannot automatically decorate function that does not support __qualname__. "
"Explicitly set the endpoint."
)
endpoint = "{}.{}".format(f.__module__, fname)
else:
endpoint = _endpoint

@wraps(f)
def wrapped(request, *args, **kwargs):
authtoken = request.META.get("HTTP_X_API_KEY", None)
if authtoken is None or not is_valid_token(endpoint, authtoken):
return HttpResponseForbidden()
return f(request, *args, **kwargs)

return wrapped

# Magic to allow decorator to be used with or without parentheses
if callable(func_or_endpoint):
func = func_or_endpoint
_endpoint = None
return decorate(func)
else:
_endpoint = func_or_endpoint
return decorate
155 changes: 154 additions & 1 deletion ietf/api/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import datetime
import json
import html
import mock
import os
import sys

Expand All @@ -12,7 +13,8 @@

from django.apps import apps
from django.conf import settings
from django.test import Client
from django.http import HttpResponseForbidden
from django.test import Client, RequestFactory
from django.test.utils import override_settings
from django.urls import reverse as urlreverse
from django.utils import timezone
Expand All @@ -38,6 +40,8 @@
from ietf.utils.models import DumpInfo
from ietf.utils.test_utils import TestCase, login_testing_unauthorized, reload_db_objects

from .ietf_utils import is_valid_token, requires_api_token

OMITTED_APPS = (
'ietf.secr.meetings',
'ietf.secr.proceedings',
Expand Down Expand Up @@ -780,7 +784,74 @@ def test_api_get_session_matherials_no_agenda_meeting_url(self):
url = urlreverse('ietf.meeting.views.api_get_session_materials', kwargs={'session_id': session.pk})
r = self.client.get(url)
self.assertEqual(r.status_code, 200)

@override_settings(APP_API_TOKENS={"ietf.api.views.email_aliases": ["valid-token"]})
@mock.patch("ietf.api.views.DraftAliasGenerator")
def test_draft_aliases(self, mock):
mock.return_value = (("alias1", ("a1", "a2")), ("alias2", ("a3", "a4")))
url = urlreverse("ietf.api.views.draft_aliases")
r = self.client.get(url, headers={"X-Api-Key": "valid-token"})
self.assertEqual(r.status_code, 200)
self.assertEqual(r.headers["Content-type"], "application/json")
self.assertEqual(
json.loads(r.content),
{
"aliases": [
{"alias": "alias1", "domains": ["ietf"], "addresses": ["a1", "a2"]},
{"alias": "alias2", "domains": ["ietf"], "addresses": ["a3", "a4"]},
]}
)
# some invalid cases
self.assertEqual(
self.client.get(url, headers={}).status_code,
403,
)
self.assertEqual(
self.client.get(url, headers={"X-Api-Key": "something-else"}).status_code,
403,
)
self.assertEqual(
self.client.post(url, headers={"X-Api-Key": "something-else"}).status_code,
403,
)
self.assertEqual(
self.client.post(url, headers={"X-Api-Key": "valid-token"}).status_code,
405,
)

@override_settings(APP_API_TOKENS={"ietf.api.views.email_aliases": ["valid-token"]})
@mock.patch("ietf.api.views.GroupAliasGenerator")
def test_group_aliases(self, mock):
mock.return_value = (("alias1", ("ietf",), ("a1", "a2")), ("alias2", ("ietf", "iab"), ("a3", "a4")))
url = urlreverse("ietf.api.views.group_aliases")
r = self.client.get(url, headers={"X-Api-Key": "valid-token"})
self.assertEqual(r.status_code, 200)
self.assertEqual(r.headers["Content-type"], "application/json")
self.assertEqual(
json.loads(r.content),
{
"aliases": [
{"alias": "alias1", "domains": ["ietf"], "addresses": ["a1", "a2"]},
{"alias": "alias2", "domains": ["ietf", "iab"], "addresses": ["a3", "a4"]},
]}
)
# some invalid cases
self.assertEqual(
self.client.get(url, headers={}).status_code,
403,
)
self.assertEqual(
self.client.get(url, headers={"X-Api-Key": "something-else"}).status_code,
403,
)
self.assertEqual(
self.client.post(url, headers={"X-Api-Key": "something-else"}).status_code,
403,
)
self.assertEqual(
self.client.post(url, headers={"X-Api-Key": "valid-token"}).status_code,
405,
)


class DirectAuthApiTests(TestCase):
Expand Down Expand Up @@ -1133,3 +1204,85 @@ def test_no_such_document(self):
url = urlreverse(self.target_view, kwargs={'name': name})
r = self.client.get(url)
self.assertEqual(r.status_code, 404)


class TokenTests(TestCase):
@override_settings(APP_API_TOKENS={"known.endpoint": ["token in a list"], "oops": "token as a str"})
def test_is_valid_token(self):
# various invalid cases
self.assertFalse(is_valid_token("unknown.endpoint", "token in a list"))
self.assertFalse(is_valid_token("known.endpoint", "token"))
self.assertFalse(is_valid_token("known.endpoint", "token as a str"))
self.assertFalse(is_valid_token("oops", "token"))
self.assertFalse(is_valid_token("oops", "token in a list"))
# the only valid cases
self.assertTrue(is_valid_token("known.endpoint", "token in a list"))
self.assertTrue(is_valid_token("oops", "token as a str"))

@mock.patch("ietf.api.ietf_utils.is_valid_token")
def test_requires_api_token(self, mock_is_valid_token):
called = False

@requires_api_token
def fn_to_wrap(request, *args, **kwargs):
nonlocal called
called = True
return request, args, kwargs

req_factory = RequestFactory()
arg = object()
kwarg = object()

# No X-Api-Key header
mock_is_valid_token.return_value = False
val = fn_to_wrap(
req_factory.get("/some/url", headers={}),
arg,
kwarg=kwarg,
)
self.assertTrue(isinstance(val, HttpResponseForbidden))
self.assertFalse(mock_is_valid_token.called)
self.assertFalse(called)

# Bad X-Api-Key header (not resetting the mock, it was not used yet)
val = fn_to_wrap(
req_factory.get("/some/url", headers={"X-Api-Key": "some-value"}),
arg,
kwarg=kwarg,
)
self.assertTrue(isinstance(val, HttpResponseForbidden))
self.assertTrue(mock_is_valid_token.called)
self.assertEqual(
mock_is_valid_token.call_args[0],
(fn_to_wrap.__module__ + "." + fn_to_wrap.__qualname__, "some-value"),
)
self.assertFalse(called)

# Valid header
mock_is_valid_token.reset_mock()
mock_is_valid_token.return_value = True
request = req_factory.get("/some/url", headers={"X-Api-Key": "some-value"})
# Bad X-Api-Key header (not resetting the mock, it was not used yet)
val = fn_to_wrap(
request,
arg,
kwarg=kwarg,
)
self.assertEqual(val, (request, (arg,), {"kwarg": kwarg}))
self.assertTrue(mock_is_valid_token.called)
self.assertEqual(
mock_is_valid_token.call_args[0],
(fn_to_wrap.__module__ + "." + fn_to_wrap.__qualname__, "some-value"),
)
self.assertTrue(called)

# Test the endpoint setting
@requires_api_token("endpoint")
def another_fn_to_wrap(request):
return "yep"

val = another_fn_to_wrap(request)
self.assertEqual(
mock_is_valid_token.call_args[0],
("endpoint", "some-value"),
)
4 changes: 4 additions & 0 deletions ietf/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,12 @@
url(r'^v2/person/person', api_views.ApiV2PersonExportView.as_view()),
#
# --- Custom API endpoints, sorted alphabetically ---
# Email alias information for drafts
url(r'^doc/draft-aliases/$', api_views.draft_aliases),
# GPRD: export of personal information for the logged-in person
url(r'^export/personal-information/$', api_views.PersonalInformationExportView.as_view()),
# Email alias information for groups
url(r'^group/group-aliases/$', api_views.group_aliases),
# Let IESG members set positions programmatically
url(r'^iesg/position', views_ballot.api_set_position),
# Let Meetecho set session video URLs
Expand Down
61 changes: 48 additions & 13 deletions ietf/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,42 +2,39 @@
# -*- coding: utf-8 -*-

import json
import pytz
import re

from jwcrypto.jwk import JWK

import pytz
from django.conf import settings
from django.contrib.auth import authenticate
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.http import HttpResponse, Http404
from django.http import HttpResponse, Http404, JsonResponse
from django.shortcuts import render, get_object_or_404
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.gzip import gzip_page
from django.views.generic.detail import DetailView

from jwcrypto.jwk import JWK
from tastypie.exceptions import BadRequest
from tastypie.utils.mime import determine_format, build_content_type
from tastypie.utils import is_valid_jsonp_callback_value
from tastypie.serializers import Serializer

import debug # pyflakes:ignore
from tastypie.utils import is_valid_jsonp_callback_value
from tastypie.utils.mime import determine_format, build_content_type

import ietf
from ietf.person.models import Person, Email
from ietf.api import _api_list
from ietf.api.ietf_utils import is_valid_token, requires_api_token
from ietf.api.serializer import JsonExportMixin
from ietf.api.ietf_utils import is_valid_token
from ietf.doc.utils import fuzzy_find_documents
from ietf.ietfauth.views import send_account_creation_email
from ietf.doc.utils import DraftAliasGenerator, fuzzy_find_documents
from ietf.group.utils import GroupAliasGenerator
from ietf.ietfauth.utils import role_required
from ietf.ietfauth.views import send_account_creation_email
from ietf.meeting.models import Meeting
from ietf.nomcom.models import Volunteer, NomCom
from ietf.person.models import Person, Email
from ietf.stats.models import MeetingRegistration
from ietf.utils import log
from ietf.utils.decorators import require_api_key
Expand Down Expand Up @@ -453,3 +450,41 @@ def directauth(request):

else:
return HttpResponse(status=405)


@requires_api_token("ietf.api.views.email_aliases")
@csrf_exempt
def draft_aliases(request):
if request.method == "GET":
return JsonResponse(
{
"aliases": [
{
"alias": alias,
"domains": ["ietf"],
"addresses": address_list,
}
for alias, address_list in DraftAliasGenerator()
]
}
)
return HttpResponse(status=405)


@requires_api_token("ietf.api.views.email_aliases")
@csrf_exempt
def group_aliases(request):
if request.method == "GET":
return JsonResponse(
{
"aliases": [
{
"alias": alias,
"domains": domains,
"addresses": address_list,
}
for alias, domains, address_list in GroupAliasGenerator()
]
}
)
return HttpResponse(status=405)
Loading