Skip to content

Commit e7209c6

Browse files
committed
Added a new personal event table to keep track of personal API key logins, and a management command to send out reports about activity to users with API keys. Added a weekly cronjob script to trigger weekly reports, and a monthly script for future use. Added a @require_api_key decorator to validate API keys for API key views and log in the API key owner. Modified the API key management urls to use create and disable rather than add and delete. Updated the API key list view. Added an API placeholder view function for ballot position setting, for test purposes. Added tests for the decorator and management command.
- Legacy-Id: 14426
1 parent 383b8b1 commit e7209c6

16 files changed

Lines changed: 425 additions & 81 deletions

File tree

bin/monthly

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
#!/bin/bash
2+
3+
# Weekly datatracker jobs.
4+
#
5+
# This script is expected to be triggered by cron from
6+
# /etc/cron.d/datatracker
7+
8+
DTDIR=/a/www/ietf-datatracker/web
9+
cd $DTDIR/
10+
11+
# Set up the virtual environment
12+
source $DTDIR/env/bin/activate
13+
14+
logger -p user.info -t cron "Running $DTDIR/bin/monthly"
15+

bin/weekly

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
#!/bin/bash
2+
3+
# Weekly datatracker jobs.
4+
#
5+
# This script is expected to be triggered by cron from
6+
# /etc/cron.d/datatracker
7+
8+
DTDIR=/a/www/ietf-datatracker/web
9+
cd $DTDIR/
10+
11+
# Set up the virtual environment
12+
source $DTDIR/env/bin/activate
13+
14+
logger -p user.info -t cron "Running $DTDIR/bin/weekly"
15+
16+
17+
# Send out weekly summaries of apikey usage
18+
19+
$DTDIR/ietf/manage.py send_apikey_usage_emails
20+

ietf/api/urls.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from django.conf.urls import include
44

55
from ietf import api
6+
from ietf.doc import views_ballot
67
from ietf.meeting import views as meeting_views
78
from ietf.submit import views as submit_views
89
from ietf.utils.urls import url
@@ -15,6 +16,7 @@
1516
# Custom API endpoints
1617
url(r'^notify/meeting/import_recordings/(?P<number>[a-z0-9-]+)/?$', meeting_views.api_import_recordings),
1718
url(r'^submit/?$', submit_views.api_submit),
19+
url(r'^iesg/position', views_ballot.api_set_position),
1820
]
1921
# Additional (standard) Tastypie endpoints
2022
for n,a in api._api_list:

ietf/doc/views_ballot.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33

44
import datetime, json
55

6-
from django.http import HttpResponseForbidden, HttpResponseRedirect, Http404
7-
from django.shortcuts import render, get_object_or_404, redirect
8-
from django.urls import reverse as urlreverse
9-
from django.template.loader import render_to_string
106
from django import forms
117
from django.conf import settings
8+
from django.http import HttpResponse, HttpResponseForbidden, HttpResponseRedirect, Http404
9+
from django.shortcuts import render, get_object_or_404, redirect
10+
from django.template.loader import render_to_string
11+
from django.urls import reverse as urlreverse
12+
from django.views.decorators.csrf import csrf_exempt
13+
1214

1315
import debug # pyflakes:ignore
1416

@@ -23,12 +25,13 @@
2325
from ietf.doc.lastcall import request_last_call
2426
from ietf.iesg.models import TelechatDate
2527
from ietf.ietfauth.utils import has_role, role_required, is_authorized_in_doc_stream
28+
from ietf.mailtrigger.utils import gather_address_lists
29+
from ietf.mailtrigger.forms import CcSelectForm
2630
from ietf.message.utils import infer_message
2731
from ietf.name.models import BallotPositionName
2832
from ietf.person.models import Person
2933
from ietf.utils.mail import send_mail_text, send_mail_preformatted
30-
from ietf.mailtrigger.utils import gather_address_lists
31-
from ietf.mailtrigger.forms import CcSelectForm
34+
from ietf.utils.decorators import require_user_api_key
3235

3336
BALLOT_CHOICES = (("yes", "Yes"),
3437
("noobj", "No Objection"),
@@ -233,6 +236,12 @@ def edit_position(request, name, ballot_id):
233236
blocking_positions=json.dumps(blocking_positions),
234237
))
235238

239+
@require_user_api_key
240+
@role_required('Area Director', 'Secretariat')
241+
@csrf_exempt
242+
def api_set_position(request):
243+
return HttpResponse("Done", status=200, content_type='text/plain')
244+
236245

237246
@role_required('Area Director','Secretariat')
238247
def send_ballot_comment(request, name, ballot_id):
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# -*- coding: utf-8 -*-
2+
# Copyright The IETF Trust 2017, All Rights Reserved
3+
from __future__ import print_function, unicode_literals
4+
5+
import datetime
6+
7+
from textwrap import dedent
8+
9+
from django.conf import settings
10+
from django.core.management.base import BaseCommand
11+
12+
import debug # pyflakes:ignore
13+
14+
from ietf.person.models import PersonalApiKey, PersonApiKeyEvent
15+
from ietf.utils.mail import send_mail
16+
17+
18+
class Command(BaseCommand):
19+
"""
20+
Send out emails to all persons who have personal API keys about usage.
21+
22+
Usage is show over the given period, where the default period is 7 days.
23+
"""
24+
25+
help = dedent(__doc__).strip()
26+
27+
def add_arguments(self, parser):
28+
parser.add_argument('-d', '--days', dest='days', type=int, default=7,
29+
help='The period over which to show usage.')
30+
31+
def handle(self, *filenames, **options):
32+
"""
33+
"""
34+
35+
self.verbosity = int(options.get('verbosity'))
36+
days = options.get('days')
37+
38+
keys = PersonalApiKey.objects.filter(valid=True)
39+
for key in keys:
40+
earliest = datetime.datetime.now() - datetime.timedelta(days=days)
41+
events = PersonApiKeyEvent.objects.filter(key=key, time__gt=earliest)
42+
count = events.count()
43+
events = events[:32]
44+
if count:
45+
key_name = key.hash()[:8]
46+
subject = "API key usage for key '%s' for the last %s days" %(key_name, days)
47+
to = key.person.email_address()
48+
frm = settings.DEFAULT_FROM_EMAIL
49+
send_mail(None, to, frm, subject, 'utils/apikey_usage_report.txt', {'person':key.person,
50+
'days':days, 'key':key, 'key_name':key_name, 'count':count, 'events':events, } )
51+

ietf/ietfauth/tests.py

Lines changed: 107 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,10 @@
1717
from ietf.utils.test_data import make_test_data, make_review_data
1818
from ietf.utils.mail import outbox, empty_outbox
1919
from ietf.group.models import Group, Role, RoleName
20+
from ietf.group.factories import GroupFactory
2021
from ietf.ietfauth.htpasswd import update_htpasswd_file
2122
from ietf.mailinglists.models import Subscribed
22-
from ietf.person.models import Person, Email
23+
from ietf.person.models import Person, Email, PersonalApiKey, PERSON_API_KEY_ENDPOINTS
2324
from ietf.person.factories import PersonFactory
2425
from ietf.review.models import ReviewWish, UnavailablePeriod
2526
from ietf.utils.decorators import skip_coverage
@@ -497,7 +498,7 @@ def test_change_username(self):
497498
self.assertEqual(prev, user)
498499
self.assertTrue(user.check_password(u'password'))
499500

500-
def test_apikey(self):
501+
def test_apikey_management(self):
501502
person = PersonFactory()
502503

503504
url = urlreverse('ietf.ietfauth.views.apikey_index')
@@ -511,33 +512,33 @@ def test_apikey(self):
511512
self.assertContains(r, 'Get a new personal API key')
512513

513514
# Check the add key form content
514-
url = urlreverse('ietf.ietfauth.views.apikey_add')
515+
url = urlreverse('ietf.ietfauth.views.apikey_create')
515516
r = self.client.get(url)
516517
self.assertContains(r, 'Create a new personal API key')
517518
self.assertContains(r, 'Endpoint')
518519

519520
# Add 2 keys
520-
r = self.client.post(url, {'endpoint': '/api/submit'})
521-
self.assertRedirects(r, urlreverse('ietf.ietfauth.views.apikey_index'))
522-
r = self.client.post(url, {'endpoint': '/api/iesg/discuss'})
523-
self.assertRedirects(r, urlreverse('ietf.ietfauth.views.apikey_index'))
521+
for endpoint, display in PERSON_API_KEY_ENDPOINTS:
522+
r = self.client.post(url, {'endpoint': endpoint})
523+
self.assertRedirects(r, urlreverse('ietf.ietfauth.views.apikey_index'))
524524

525525
# Check api key list content
526526
url = urlreverse('ietf.ietfauth.views.apikey_index')
527527
r = self.client.get(url)
528-
self.assertContains(r, '/api/submit')
529-
self.assertContains(r, '/api/iesg/discuss')
528+
for endpoint, display in PERSON_API_KEY_ENDPOINTS:
529+
self.assertContains(r, endpoint)
530530
q = PyQuery(r.content)
531-
self.assertEqual(len(q('td code')), 2)
531+
self.assertEqual(len(q('td code')), len(PERSON_API_KEY_ENDPOINTS)) # hash
532+
self.assertEqual(len(q('td a:contains("Disable")')), len(PERSON_API_KEY_ENDPOINTS))
532533

533534
# Get one of the keys
534535
key = person.apikeys.first()
535536

536-
# Check the delete key form content
537-
url = urlreverse('ietf.ietfauth.views.apikey_del')
537+
# Check the disable key form content
538+
url = urlreverse('ietf.ietfauth.views.apikey_disable')
538539
r = self.client.get(url)
539540

540-
self.assertContains(r, 'Delete a personal API key')
541+
self.assertContains(r, 'Disable a personal API key')
541542
self.assertContains(r, 'Key')
542543

543544
# Delete a key
@@ -547,7 +548,98 @@ def test_apikey(self):
547548
# Check the api key list content again
548549
url = urlreverse('ietf.ietfauth.views.apikey_index')
549550
r = self.client.get(url)
550-
self.assertNotContains(r, key.endpoint)
551551
q = PyQuery(r.content)
552-
self.assertEqual(len(q('td code')), 1)
552+
self.assertEqual(len(q('td code')), len(PERSON_API_KEY_ENDPOINTS)) # key hash
553+
self.assertEqual(len(q('td a:contains("Disable")')), len(PERSON_API_KEY_ENDPOINTS)-1)
554+
555+
def test_apikey_usage(self):
556+
BAD_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
557+
558+
person = PersonFactory()
559+
area = GroupFactory(type_id='area')
560+
area.role_set.create(name_id='ad', person=person, email=person.email())
561+
562+
url = urlreverse('ietf.ietfauth.views.apikey_create')
563+
# Check that the url is protected, then log in
564+
login_testing_unauthorized(self, person.user.username, url)
565+
566+
# Add keys
567+
for endpoint, display in PERSON_API_KEY_ENDPOINTS:
568+
r = self.client.post(url, {'endpoint': endpoint})
569+
self.assertRedirects(r, urlreverse('ietf.ietfauth.views.apikey_index'))
570+
571+
for key in person.apikeys.all()[:3]:
572+
url = key.endpoint
573+
574+
# successful access
575+
r = self.client.post(url, {'apikey':key.hash(), 'dummy':'dummy',})
576+
self.assertEqual(r.status_code, 200)
577+
578+
# bad method
579+
r = self.client.put(url, {'apikey':key.hash()})
580+
self.assertEqual(r.status_code, 405)
581+
582+
# missing apikey
583+
r = self.client.post(url, {'dummy':'dummy',})
584+
self.assertEqual(r.status_code, 400)
585+
self.assertIn('Missing apikey parameter', unicontent(r))
586+
587+
# invalid apikey
588+
r = self.client.post(url, {'apikey':BAD_KEY, 'dummy':'dummy',})
589+
self.assertEqual(r.status_code, 400)
590+
self.assertIn('Invalid apikey', unicontent(r))
591+
592+
# too long since regular login
593+
person.user.last_login = datetime.datetime.now() - datetime.timedelta(days=settings.UTILS_APIKEY_GUI_LOGIN_LIMIT_DAYS+1)
594+
person.user.save()
595+
r = self.client.post(url, {'apikey':key.hash(), 'dummy':'dummy',})
596+
self.assertEqual(r.status_code, 400)
597+
self.assertIn('Too long since last regular login', unicontent(r))
598+
person.user.last_login = datetime.datetime.now()
599+
person.user.save()
600+
601+
# endpoint mismatch
602+
key2 = PersonalApiKey.objects.create(person=person, endpoint='/')
603+
r = self.client.post(url, {'apikey':key2.hash(), 'dummy':'dummy',})
604+
self.assertEqual(r.status_code, 400)
605+
self.assertIn('Apikey endpoint mismatch', unicontent(r))
606+
key2.delete()
607+
608+
def test_send_apikey_report(self):
609+
from ietf.ietfauth.management.commands.send_apikey_usage_emails import Command
610+
from ietf.utils.mail import outbox, empty_outbox
611+
612+
person = PersonFactory()
613+
614+
url = urlreverse('ietf.ietfauth.views.apikey_create')
615+
# Check that the url is protected, then log in
616+
login_testing_unauthorized(self, person.user.username, url)
617+
618+
# Add keys
619+
for endpoint, display in PERSON_API_KEY_ENDPOINTS:
620+
r = self.client.post(url, {'endpoint': endpoint})
621+
self.assertRedirects(r, urlreverse('ietf.ietfauth.views.apikey_index'))
622+
623+
# Use the endpoints (the form content will not be acceptable, but the
624+
# apikey usage will be registered)
625+
count = 2
626+
# avoid usage across dates
627+
if datetime.datetime.now().time() > datetime.time(hour=23, minute=59, second=58):
628+
time.sleep(2)
629+
for i in range(count):
630+
for key in person.apikeys.all():
631+
url = key.endpoint
632+
self.client.post(url, {'apikey':key.hash(), 'dummy': 'dummy', })
633+
date = str(datetime.date.today())
634+
635+
empty_outbox()
636+
cmd = Command()
637+
cmd.handle(verbosity=0, days=7)
638+
639+
self.assertEqual(len(outbox), len(PERSON_API_KEY_ENDPOINTS))
640+
for mail in outbox:
641+
body = mail.get_payload()
642+
self.assertIn("API key usage", mail['subject'])
643+
self.assertIn(" %s times" % count, body)
644+
self.assertIn(date, body)
553645

ietf/ietfauth/urls.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
urlpatterns = [
99
url(r'^$', views.index),
1010
url(r'^apikey/?$', views.apikey_index),
11-
url(r'^apikey/add/?$', views.apikey_add),
12-
url(r'^apikey/del/?$', views.apikey_del),
11+
url(r'^apikey/add/?$', views.apikey_create),
12+
url(r'^apikey/del/?$', views.apikey_disable),
1313
url(r'^confirmnewemail/(?P<auth>[^/]+)/$', views.confirm_new_email),
1414
url(r'^create/$', views.create_account),
1515
url(r'^create/confirm/(?P<auth>[^/]+)/$', views.confirm_account),

ietf/ietfauth/views.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -602,7 +602,7 @@ def apikey_index(request):
602602

603603
@login_required
604604
@person_required
605-
def apikey_add(request):
605+
def apikey_create(request):
606606
class ApiKeyForm(forms.ModelForm):
607607
class Meta:
608608
model = PersonalApiKey
@@ -623,7 +623,7 @@ class Meta:
623623

624624
@login_required
625625
@person_required
626-
def apikey_del(request):
626+
def apikey_disable(request):
627627
person = request.user.person
628628
choices = [ (k.hash(), str(k)) for k in person.apikeys.all() ]
629629
#
@@ -642,11 +642,12 @@ def clean_key(self):
642642
if form.is_valid():
643643
hash = form.data['hash']
644644
key = PersonalApiKey.validate_key(hash)
645-
key.delete()
646-
messages.success(request, "Deleted key %s" % hash)
645+
key.valid = False
646+
key.save()
647+
messages.success(request, "Disabled key %s" % hash)
647648
return redirect('ietf.ietfauth.views.apikey_index')
648649
else:
649-
messages.error(request, "Key validation failed; key not deleted")
650+
messages.error(request, "Key validation failed; key not disabled")
650651
else:
651652
form = KeyDeleteForm(request.GET)
652-
return render(request, 'form.html', {'form':form, 'title':"Delete a personal API key", 'description':'', 'button':'Delete key'})
653+
return render(request, 'form.html', {'form':form, 'title':"Disable a personal API key", 'description':'', 'button':'Disable key'})

ietf/person/admin.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from django.contrib import admin
22

33

4-
from ietf.person.models import Email, Alias, Person, PersonHistory, PersonalApiKey
4+
from ietf.person.models import Email, Alias, Person, PersonHistory, PersonalApiKey, PersonEvent, PersonApiKeyEvent
55
from ietf.person.name import name_parts
66

77
class EmailAdmin(admin.ModelAdmin):
@@ -49,3 +49,15 @@ class PersonalApiKeyAdmin(admin.ModelAdmin):
4949
raw_id_fields = ['person', ]
5050
search_fields = ['person__name', ]
5151
admin.site.register(PersonalApiKey, PersonalApiKeyAdmin)
52+
53+
class PersonEventAdmin(admin.ModelAdmin):
54+
list_display = ["id", "person", "time", "type", ]
55+
search_fields = ["person__name", ]
56+
raw_id_fields = ['person', ]
57+
admin.site.register(PersonEvent, PersonEventAdmin)
58+
59+
class PersonApiKeyEventAdmin(admin.ModelAdmin):
60+
list_display = ["id", "person", "time", "type", "key"]
61+
search_fields = ["person__name", ]
62+
raw_id_fields = ['person', ]
63+
admin.site.register(PersonApiKeyEvent, PersonApiKeyEventAdmin)

0 commit comments

Comments
 (0)