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: 2 additions & 0 deletions docker/configs/settings_local.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@
APP_API_TOKENS = {
"ietf.api.red_api" : ["devtoken", "redtoken"], # Not a real secret
"ietf.api.views_rpc" : ["devtoken"], # Not a real secret
"ietf.person.api_uuid" : ["devtoken"], # Not a real secret
"ietf.person.api_uuid_by_pk" : ["devtoken"], # Not a real secret
}

# Errata system api configuration
Expand Down
19 changes: 19 additions & 0 deletions ietf/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from ietf import api
from ietf.doc import views_ballot, api as doc_api
from ietf.meeting import views as meeting_views
from ietf.person import api_uuid as person_uuid_api
from ietf.submit import views as submit_views
from ietf.utils.urls import url

Expand All @@ -21,6 +22,14 @@
# core_router.register("email", person_api.EmailViewSet)
# core_router.register("person", person_api.PersonViewSet)

# Person identity API router
person_router = PrefixedSimpleRouter(
use_regex_path=False, name_prefix="ietf.api.person_api"
)
person_router.register(
"uuid", person_uuid_api.PersonUUIDViewSet, basename="person-uuid"
)

# todo more general name for this API?
red_router = PrefixedSimpleRouter(name_prefix="ietf.api.red_api") # red api router
red_router.register("doc", doc_api.RfcViewSet)
Expand Down Expand Up @@ -88,6 +97,16 @@
url(r'^person/email/$', api_views.active_email_list),
# Related Email listing
url(r'^person/email/(?P<email>[^/\x00]+)/related/$', api_views.related_email_list),
# Transitional pk-to-UUID conversion. Before the router include below so it wins
# over the router's uuid/ routes.
path(
"person/uuid/by-person-pk/",
person_uuid_api.PersonUUIDByPersonPkView.as_view(),
name="ietf.api.person_api.person-uuid-by-pk",
),
# Person UUID resolution API. After the ^person/email/ routes above so those keep
# matching first.
path("person/", include(person_router.urls)),
# Draft submission API
url(r'^submit/?$', submit_views.api_submit_tombstone),
# Draft upload API
Expand Down
43 changes: 39 additions & 4 deletions ietf/ietfauth/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,13 @@
from ietf.ietfauth.utils import has_role
from ietf.meeting.factories import MeetingFactory, RegistrationFactory, RegistrationTicketFactory
from ietf.nomcom.factories import NomComFactory
from ietf.person.factories import PersonFactory, EmailFactory, UserFactory, PersonalApiKeyFactory
from ietf.person.factories import (
PersonFactory,
EmailFactory,
UserFactory,
PersonalApiKeyFactory,
PersonUUIDFactory,
)
from ietf.person.models import Person, Email
from ietf.person.tasks import send_apikey_usage_emails_task
from ietf.review.factories import ReviewRequestFactory, ReviewAssignmentFactory
Expand Down Expand Up @@ -657,7 +663,9 @@ def test_change_password(self):
)
user.set_password(VALID_PASSWORD)
user.save()
p = Person.objects.create(name="Some One", ascii="Some One", user=user)
p = PersonFactory(
user=user, name="Some One", ascii="Some One", default_emails=False
)
Email.objects.create(address=user.username, person=p, origin=user.username)

# log in
Expand Down Expand Up @@ -758,7 +766,9 @@ def test_change_username(self):
)
user.set_password(VALID_PASSWORD)
user.save()
p = Person.objects.create(name="Some One", ascii="Some One", user=user)
p = PersonFactory(
user=user, name="Some One", ascii="Some One", default_emails=False
)
Email.objects.create(address=user.username, person=p, origin=user.username)
Email.objects.create(
address="othername@example.org", person=p, origin=user.username
Expand Down Expand Up @@ -1162,7 +1172,16 @@ def test_oidc_code_auth(self):
session["nonce"] = rndstr()
args = {
"response_type": "code",
"scope": ['openid', 'profile', 'email', 'roles', 'registration', 'dots', 'pronouns' ],
"scope": [
"openid",
"profile",
"email",
"roles",
"registration",
"dots",
"pronouns",
"datatracker_uuid",
],
"nonce": session["nonce"],
"redirect_uri": redirect_uris[0],
"state": session["state"]
Expand Down Expand Up @@ -1207,6 +1226,10 @@ def test_oidc_code_auth(self):
self.assertIn(key, access_token_info)
for key in ['iss', 'sub', 'aud', 'exp', 'iat', 'auth_time', 'nonce', 'at_hash']:
self.assertIn(key, access_token_info['id_token'])
# Custom claims are served from userinfo, not the id_token. This guards
# against an accidental OIDC_IDTOKEN_INCLUDE_CLAIMS flip.
for key in ["datatracker_uuid", "datatracker_prior_uuids"]:
self.assertNotIn(key, access_token_info["id_token"])

# Get userinfo, check keys present, most common scenario
userinfo = client.do_user_info_request(state=params["state"], scope=args['scope'])
Expand All @@ -1218,6 +1241,18 @@ def test_oidc_code_auth(self):
self.assertNotIn('hackathon_onsite', set(userinfo['reg_type'].split()))
self.assertIn(active_group.acronym, [i[1] for i in userinfo['roles']])
self.assertNotIn(closed_group.acronym, [i[1] for i in userinfo['roles']])
self.assertEqual(userinfo['datatracker_uuid'], str(person.primary_uuid))
# Present and empty, not absent, for a Person that has never been merged
self.assertIn("datatracker_prior_uuids", userinfo)
self.assertEqual(userinfo["datatracker_prior_uuids"], [])

# A UUID absorbed by a merge shows up in the prior list
absorbed = PersonUUIDFactory(person=person)
userinfo = client.do_user_info_request(
state=params["state"], scope=args["scope"]
)
self.assertEqual(userinfo["datatracker_uuid"], str(person.primary_uuid))
self.assertEqual(userinfo["datatracker_prior_uuids"], [str(absorbed.uuid)])

# Create a registration, with only email, no person (rare if at all)
reg_person.delete()
Expand Down
23 changes: 23 additions & 0 deletions ietf/ietfauth/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,29 @@ def scope_dots(self):
dots = get_dots(self.user.person)
return { 'dots': dots }

info_datatracker_uuid = (
"Datatracker person identifiers",
(
"Access to the stable identifier the datatracker uses for you when "
"telling other systems who you are, and to any identifiers it used for "
"you before they were superseded."
),
)

def scope_datatracker_uuid(self):
# One scope for both claims: there is no case for granting the current
# identifier without the superseded ones that resolve to it.
person = self.user.person
return {
# An empty string is dropped by ScopeClaims._clean_dic, so an inconsistent
# Person yields an absent claim rather than a bogus identifier.
"datatracker_uuid": str(person.primary_uuid or ""),
# An empty list survives _clean_dic, so this claim is present-and-empty
# rather than absent for a Person that has never been merged. It holds only
# superseded identifiers - the current one is datatracker_uuid.
"datatracker_prior_uuids": [str(u) for u in person.prior_uuids],
}

def scope_pronouns(self):
return { 'pronouns': self.user.person.pronouns() }

Expand Down
16 changes: 10 additions & 6 deletions ietf/ietfauth/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
from django.contrib.auth.views import LoginView
from django.contrib.sites.models import Site
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db import IntegrityError
from django.db import IntegrityError, transaction
from django.urls import reverse as urlreverse
from django.http import Http404, HttpResponseRedirect, HttpResponseForbidden
from django.shortcuts import render, redirect, get_object_or_404
Expand All @@ -69,6 +69,7 @@
from ietf.name.models import ExtResourceName
from ietf.nomcom.models import NomCom
from ietf.person.models import Person, Email, Alias, PersonalApiKey, PERSON_API_KEY_VALUES
from ietf.person.utils import assign_primary_uuid
from ietf.review.models import ReviewerSettings, ReviewWish, ReviewAssignment
from ietf.review.utils import unavailable_periods_to_list, get_default_filter_re
from ietf.doc.fields import SearchableDocumentField
Expand Down Expand Up @@ -232,12 +233,15 @@ def confirm_account(request, auth):
if not person:
name = form.cleaned_data["name"]
ascii = form.cleaned_data["ascii"]
person = Person.objects.create(user=user,
name=name,
ascii=ascii)

for name in set([ person.name, person.ascii, person.plain_name(), person.plain_ascii(), ]):
Alias.objects.create(person=person, name=name)
# Atomic so a Person is never left without the primary UUID that
# external systems need to name them by.
with transaction.atomic():
person = Person.objects.create(user=user, name=name, ascii=ascii)
assign_primary_uuid(person)

for name in set([ person.name, person.ascii, person.plain_name(), person.plain_ascii(), ]):
Alias.objects.create(person=person, name=name)

if not email_obj:
email_obj = Email.objects.create(address=email, person=person, origin=user.username)
Expand Down
20 changes: 13 additions & 7 deletions ietf/nomcom/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from email.utils import parseaddr
from textwrap import dedent

from django.db import transaction
from django.db.models import Q, Count, F, QuerySet
from django.conf import settings
from django.contrib.sites.models import Site
Expand All @@ -36,6 +37,7 @@
from ietf.utils.mail import send_mail_text, send_mail, get_payload_text
from ietf.utils.log import log
from ietf.person.name import unidecode_name
from ietf.person.utils import assign_primary_uuid
from ietf.utils.timezone import date_today, datetime_from_date, DEADLINE_TZINFO

import debug # pyflakes:ignore
Expand Down Expand Up @@ -416,13 +418,17 @@ def make_nomineeposition(nomcom, candidate, position, author):

def make_nomineeposition_for_newperson(nomcom, candidate_name, candidate_email, position, author):

# This is expected to fail if called with an existing email address
email = Email.objects.create(address=candidate_email, origin="nominee: %s" % nomcom.group.acronym)
person = Person.objects.create(name=candidate_name,
ascii=unidecode_name(candidate_name),
)
email.person = person
email.save()
# This is expected to fail if called with an existing email address.
# Atomic so a Person is never left without the primary UUID that external systems
# need to name them by, and so a failure part way leaves no half-built nominee.
with transaction.atomic():
email = Email.objects.create(address=candidate_email, origin="nominee: %s" % nomcom.group.acronym)
person = Person.objects.create(name=candidate_name,
ascii=unidecode_name(candidate_name),
)
assign_primary_uuid(person)
email.person = person
email.save()

# send email to secretariat and nomcomchair to warn about the new person
subject = 'New person is created'
Expand Down
55 changes: 53 additions & 2 deletions ietf/person/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@
import simple_history

from django import forms
from django.contrib import messages
from django.db import transaction

from ietf.person.models import Email, Alias, Person, PersonalApiKey, PersonEvent, PersonApiKeyEvent, PersonExtResource
from ietf.person.models import Email, Alias, Person, PersonalApiKey, PersonEvent, \
PersonApiKeyEvent, PersonExtResource, PersonUUID
from ietf.person.name import name_parts
from ietf.person.utils import queue_person_uuid_push

from ietf.utils.admin import SaferStackedInline, SaferTabularInline
from ietf.utils.validators import validate_external_resource_value
Expand All @@ -29,6 +33,53 @@ class AliasAdmin(admin.ModelAdmin):
class AliasInline(SaferStackedInline):
model = Alias


@admin.action(description="Make this the person's primary UUID")
def set_primary(modeladmin, request, queryset):
"""Re-designate a Person's primary UUID

Acts on exactly one UUID at a time: promoting two at once would either violate the
one-primary-per-person constraint or silently ignore one of them.
"""
if queryset.count() != 1:
modeladmin.message_user(
request, "Select exactly one UUID.", level=messages.ERROR
)
return
new_primary = queryset.first()
if new_primary.primary:
modeladmin.message_user(request, "That UUID is already primary.")
return
person = new_primary.person
with transaction.atomic():
person.uuids.filter(primary=True).update(primary=False)
new_primary.primary = True
new_primary.save(update_fields=["primary"])
queue_person_uuid_push(person)
modeladmin.message_user(
request, f"{new_primary.uuid} is now the primary UUID for {person}."
)


class PersonUUIDAdmin(admin.ModelAdmin):
list_display = ["uuid", "person", "primary", "time"] # noqa: RUF012
list_filter = ["primary"] # noqa: RUF012
search_fields = ["uuid", "person__name"] # noqa: RUF012
raw_id_fields = ["person"] # noqa: RUF012
readonly_fields = ["uuid", "primary", "time"] # noqa: RUF012
actions = [set_primary] # noqa: RUF012
admin.site.register(PersonUUID, PersonUUIDAdmin)


class PersonUUIDInline(SaferStackedInline):
model = PersonUUID
extra = 0
# primary is changed through the PersonUUID admin's set_primary action, which demotes
# the old primary first. Editing it here would trip the uniqueness constraint.
readonly_fields = ["uuid", "primary", "time"] # noqa: RUF012
can_delete = False


class PersonAdmin(simple_history.admin.SimpleHistoryAdmin):
def plain_name(self, obj):
if obj.plain:
Expand All @@ -41,7 +92,7 @@ def plain_name(self, obj):
readonly_fields = ("name_from_draft", )
search_fields = ["name", "ascii"]
raw_id_fields = ["user"]
inlines = [ EmailInline, AliasInline, ]
inlines = [ EmailInline, AliasInline, PersonUUIDInline]
# actions = None
admin.site.register(Person, PersonAdmin)

Expand Down
Loading
Loading