-
Notifications
You must be signed in to change notification settings - Fork 18
feat: Merge person API call #332
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
kesara
wants to merge
11
commits into
ietf-tools:main
Choose a base branch
from
kesara:feat/author-merge
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from 9 commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
f2a7d36
feat: Add framework for token based authentication
kesara 70a00d2
test: Improve tests
kesara f3a6413
feat: Add API Authentication DRF class
kesara 58275b1
chore: Move files to utils
kesara a2baac9
feat: Add merge person API call
kesara b07c6ff
chore: Rename API endpoint name
kesara 07ecac1
test: Add tests for the person merge API call
kesara 3edefba
test: Test for API call without X_API_KEY header
kesara bcd993e
chore: Add extra check for API token wrapper
kesara 62f5c20
Merge branch 'main' into fork/kesara/feat/author-merge
jennifer-richards 2cff3ad
fix: lint
jennifer-richards File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| # Copyright The IETF Trust 2025, All Rights Reserved | ||
|
|
||
| from rest_framework import serializers | ||
|
|
||
|
|
||
| class MergeAuthorSerializer(serializers.Serializer): | ||
| old_person_id = serializers.SerializerMethodField() | ||
| new_person_id = serializers.SerializerMethodField() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| # Copyright The IETF Trust 2025, All Rights Reserved | ||
|
|
||
| import json | ||
|
|
||
| from django.test import Client, TestCase | ||
| from django.test.utils import override_settings | ||
|
|
||
|
|
||
| class ApiTests(TestCase): | ||
| @override_settings( | ||
| APP_API_TOKENS={ | ||
| "purple.api.merge_person": ["valid-token"], | ||
| } | ||
| ) | ||
| def test_merge_person(self): | ||
| url = "/api/merge_person/" | ||
| client = Client() | ||
| data = {"old_person_id": 12, "new_person_id": 13} | ||
| headers_valid = {"X_API_KEY": "valid-token"} | ||
| headers_invalid = {"X_API_KEY": "INVALID-TOKEN"} | ||
|
|
||
| # POST: without an API token | ||
| response = client.post( | ||
| url, data=json.dumps(data), content_type="application/json" | ||
| ) | ||
| self.assertEqual(response.status_code, 403) | ||
|
|
||
| # POST: invalid token | ||
| response = client.post( | ||
| url, | ||
| data=json.dumps(data), | ||
| content_type="application/json", | ||
| headers=headers_invalid, | ||
| ) | ||
| self.assertEqual(response.status_code, 403) | ||
|
|
||
| # POST: valid token with no data | ||
| response = client.post(url, headers=headers_valid) | ||
|
|
||
| # POST: valid token with incomplete data | ||
| incomplete_data = {"old_person_id": 12} | ||
| response = client.post( | ||
| url, | ||
| data=json.dumps(incomplete_data), | ||
| content_type="application/json", | ||
| headers=headers_valid, | ||
| ) | ||
| self.assertEqual(response.status_code, 400) | ||
| self.assertIn("This field is required.", response.json()["new_person_id"]) | ||
|
|
||
| # POST: valid token with incomplete data | ||
| incomplete_data = {"new_person_id": 12} | ||
| response = client.post( | ||
| url, | ||
| data=json.dumps(incomplete_data), | ||
| content_type="application/json", | ||
| headers=headers_valid, | ||
| ) | ||
| self.assertEqual(response.status_code, 400) | ||
| self.assertIn("This field is required.", response.json()["old_person_id"]) | ||
|
|
||
| ## POST valid request and token | ||
| response = client.post( | ||
| url, | ||
| data=json.dumps(data), | ||
| content_type="application/json", | ||
| headers=headers_valid, | ||
| ) | ||
| self.assertEqual(response.status_code, 200) | ||
| self.assertTrue(response.json()["success"]) | ||
|
|
||
| # GET: valid token | ||
| response = client.get(url, headers={"X_API_KEY": "valid-token"}) | ||
| self.assertEqual(response.status_code, 405) | ||
|
|
||
| # GET: invalid token | ||
| response = client.get(url, headers={"X_API_KEY": "invalid-token"}) | ||
| self.assertEqual(response.status_code, 403) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| # Copyright The IETF Trust 2025, All Rights Reserved | ||
|
|
||
| from functools import wraps | ||
| from typing import Callable, Optional, Union | ||
|
|
||
| from django.conf import settings | ||
| from django.http import HttpResponseForbidden | ||
| from rest_framework.request import Request | ||
| from rest_framework.views import APIView | ||
|
|
||
|
|
||
| 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: | ||
| 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): | ||
| if not isinstance(request, Request) and isinstance(request, APIView): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good idea, though I think Maybe you could go full pythonic and just do |
||
| request = request.request | ||
| 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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| # Copyright The IETF Trust 2025, All Rights Reserved | ||
|
|
||
| from rest_framework import authentication | ||
| from django.contrib.auth.models import AnonymousUser | ||
|
|
||
|
|
||
| class ApiKeyAuthentication(authentication.BaseAuthentication): | ||
| """API-Key header authentication""" | ||
|
|
||
| def authenticate(self, request): | ||
| """Extract the authentication token, if present | ||
|
|
||
| This does not validate the token, it just arranges for it to be available in request.auth. | ||
| It's up to a Permissions class to validate it for the appropriate endpoint. | ||
| """ | ||
| token = request.META.get("HTTP_X_API_KEY", None) | ||
| if token is None: | ||
| return None | ||
| return AnonymousUser(), token # available as request.user and request.auth |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| # Copyright The IETF Trust 2025, All Rights Reserved | ||
|
|
||
| from rest_framework import permissions | ||
|
|
||
| from utils.api import is_valid_token | ||
|
|
||
|
|
||
| class HasApiKey(permissions.BasePermission): | ||
| """Permissions class that validates a token using is_valid_token | ||
|
|
||
| The view class must indicate the relevant endpoint by setting `api_key_endpoint`. | ||
| Must be used with an Authentication class that puts a token in request.auth. | ||
| """ | ||
|
|
||
| def has_permission(self, request, view): | ||
| endpoint = getattr(view, "api_key_endpoint", None) | ||
| auth_token = getattr(request, "auth", None) | ||
| if endpoint is not None and auth_token is not None: | ||
| return is_valid_token(endpoint, auth_token) | ||
| return False |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| # Copyright The IETF Trust 2025, All Rights Reserved | ||
|
|
||
| from django.test import TestCase, RequestFactory | ||
| from django.test.utils import override_settings | ||
|
|
||
| from utils.api import is_valid_token, requires_api_token | ||
|
|
||
|
|
||
| class ApiTests(TestCase): | ||
| @override_settings( | ||
| APP_API_TOKENS={ | ||
| "purple.api.foobar": ["valid-token"], | ||
| "purple.api.misconfigured": "valid-token", # misconfigured | ||
| } | ||
| ) | ||
| def test_is_valid_token(self): | ||
| self.assertFalse(is_valid_token("purple.fake.endpoint", "valid-token")) | ||
| self.assertFalse(is_valid_token("purple.api.foobar", "invalid-token")) | ||
| self.assertFalse(is_valid_token("purple.api.foobar", None)) | ||
| self.assertTrue(is_valid_token("purple.api.foobar", "valid-token")) | ||
|
|
||
| # misconfiguration | ||
| self.assertFalse(is_valid_token("purple.api.misconfigured", "v")) | ||
| self.assertFalse(is_valid_token("purple.api.misconfigured", None)) | ||
| self.assertTrue(is_valid_token("purple.api.misconfigured", "valid-token")) | ||
|
|
||
| @override_settings( | ||
| APP_API_TOKENS={ | ||
| "purple.api.foo": ["valid-token"], | ||
| "purple.api.bar": ["another-token"], | ||
| "purple.api.misconfigured": "valid-token", # misconfigured | ||
| } | ||
| ) | ||
| def test_requires_api_token(self): | ||
| @requires_api_token("purple.api.foo") | ||
| def protected_function(request): | ||
| return f"Access granted: {request.method}" | ||
|
|
||
| # request with a valid token | ||
| request = RequestFactory().get( | ||
| "/some/url", headers={"X_API_KEY": "valid-token"} | ||
| ) | ||
| result = protected_function(request) | ||
| self.assertEqual(result, "Access granted: GET") | ||
|
|
||
| # request with an invalid token | ||
| request = RequestFactory().get( | ||
| "/some/url", headers={"X_API_KEY": "invalid-token"} | ||
| ) | ||
| result = protected_function(request) | ||
| self.assertEqual(result.status_code, 403) | ||
|
|
||
| # request without a token | ||
| request = RequestFactory().get("/some/url", headers={"X_API_KEY": ""}) | ||
| result = protected_function(request) | ||
| self.assertEqual(result.status_code, 403) | ||
|
|
||
| # request without X_API_KEY header | ||
| request = RequestFactory().get("/some/url") | ||
| result = protected_function(request) | ||
| self.assertEqual(result.status_code, 403) | ||
|
|
||
| # request with a valid token for another API endpoint | ||
| request = RequestFactory().get( | ||
| "/some/url", headers={"X_API_KEY": "another-token"} | ||
| ) | ||
| result = protected_function(request) | ||
| self.assertEqual(result.status_code, 403) | ||
|
|
||
| # requests for a misconfigured endpoint | ||
| @requires_api_token("purple.api.misconfigured") | ||
| def another_protected_function(request): | ||
| return f"Access granted: {request.method}" | ||
|
|
||
| # request with valid token | ||
| request = RequestFactory().get( | ||
| "/some/url", headers={"X_API_KEY": "valid-token"} | ||
| ) | ||
| result = another_protected_function(request) | ||
| self.assertEqual(result, "Access granted: GET") | ||
|
|
||
| # request with invalid token with the correct initial character | ||
| request = RequestFactory().get("/some/url", headers={"X_API_KEY": "v"}) | ||
| result = another_protected_function(request) | ||
| self.assertEqual(result.status_code, 403) |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this step should update any references to
DatatrackerPersonwithold_person_idtoDatatrackerPersonwithnew_person_id.May be delete the old record afterwards?
(@jennifer-richards)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As we discussed separately, I am hoping we can avoid having to rewrite a lot of records. If we can let things work with multiple
DatatrackerPersoninstances holding the samedatatracker_id(i.e., removing the current unique constraint plus some additional work), a merge may not need to be much more complicated than what you suggest here.