Skip to content
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

REST API endpoint for tokens #1375

Closed
wants to merge 1 commit into from
Closed

REST API endpoint for tokens #1375

wants to merge 1 commit into from

Conversation

imranh2
Copy link
Contributor

@imranh2 imranh2 commented Feb 15, 2022

Taken from: netbox-community/netbox#6592

author: jeremystretch jstretch@ns1.com

Fixes: #1374

Port over patches to allow API token creation.

@imranh2
Copy link
Contributor Author

imranh2 commented Feb 15, 2022

Didn't test it locally at all, just basically git show and git apply.

Will see what CI says tests wise.

@glennmatthews
Copy link
Contributor

Thanks for the PR! Reviewing the history of that commit in NetBox, I see that it was part of netbox-community/netbox#6592 - did you consider pulling in the entire PR?

Also, as this is a new feature, I'm going to retarget this PR from develop to next.

@glennmatthews glennmatthews changed the base branch from develop to next February 15, 2022 13:59
@imranh2
Copy link
Contributor Author

imranh2 commented Feb 15, 2022

I didn't notice it was part of a larger PR, good spot. I shall probably pull the rest in if it makes sense.

@imranh2 imranh2 changed the title Add a REST API endpoint to provision new tokens using username & pass… REST API endpoint for tokens Feb 15, 2022
@imranh2
Copy link
Contributor Author

imranh2 commented Feb 15, 2022

Squashed all the commits from that linked PR in Netbox and quickly tried to apply it to Nautobot, expecting tests to fail in this first push.

Also need to rebase on my end on to next

@bryanculver bryanculver added the type: feature Introduction of new or enhanced functionality to the application label Feb 16, 2022
@imranh2
Copy link
Contributor Author

imranh2 commented Feb 16, 2022

Actually ran flake8 locally this time.

Also think I got the only place where the codebases weren't the same.

@imranh2
Copy link
Contributor Author

imranh2 commented Feb 16, 2022

Went over the code with flake8 and black now.

nautobot/docs/release-notes/version-1.2.md Outdated Show resolved Hide resolved
@@ -66,6 +67,26 @@ class Meta:
fields = ("id", "url", "name", "user_count")


class TokenSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name="users-api:token-detail")
key = serializers.CharField(min_length=40, max_length=40, allow_blank=True, required=False)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Outside the scope of this PR, but we'll need to keep this in mind when we get to addressing #941.

nautobot/users/api/views.py Outdated Show resolved Hide resolved
nautobot/users/api/serializers.py Show resolved Hide resolved
nautobot/docs/release-notes/version-1.2.md Outdated Show resolved Hide resolved
nautobot/users/filters.py Outdated Show resolved Hide resolved
nautobot/users/models.py Show resolved Hide resolved
@imranh2
Copy link
Contributor Author

imranh2 commented Feb 18, 2022

Testing all green on my end https://github.com/imranh2/nautobot/runs/5252733577

@imranh2
Copy link
Contributor Author

imranh2 commented Feb 25, 2022

@glennmatthews Can I get another review on this please?

nautobot/docs/release-notes/version-1.3.md Outdated Show resolved Hide resolved
nautobot/docs/release-notes/version-1.3.md Outdated Show resolved Hide resolved
nautobot/docs/release-notes/version-1.3.md Outdated Show resolved Hide resolved
Comment on lines +95 to +105
self.create_data = [
{
"user": self.user.pk,
},
{
"user": self.user.pk,
},
{
"user": self.user.pk,
},
]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this is interesting... reading this here made me realize that TokenViewSet is actually a full-featured API view set of its own, meaning that it can itself be used to create, bulk-create, update, and bulk-update Tokens, in addition to the specific Token creation flow enabled by TokenProvisionView. The overridden get_queryset method does mean that a non-superuser can at least only create/update tokens for their own user account, so I think this is OK, but just leaving a comment here as I'd missed this on initial review.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On re-review I actually have some uncertainty here - would you mind adding a few more API test cases for safety:

  • User A tries to create a token belonging to User B (should fail with an appropriate error)
  • User B queries for a list of tokens (confirm that User A's tokens do not appear in the list)
  • User A tries to update their own token so that it refers to User B as the owner (should fail with an appropriate error)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the crossed wires here - since addressing Bryan's comments about removing the user field from the serializer entirely, my first and third suggestions are no longer applicable. I'll offer a suggested implementation below.

nautobot/users/tests/test_api.py Show resolved Hide resolved
@imranh2
Copy link
Contributor Author

imranh2 commented Mar 2, 2022

@glennmatthews Thanks for the review, sorry for the delay, I saw you'd replied and set aside a chunk of time to go through it but you only wanted minor changes which took less time than waiting for my test to run :)

Testing green on my end.

nautobot/users/filters.py Outdated Show resolved Hide resolved
@imranh2
Copy link
Contributor Author

imranh2 commented Mar 4, 2022

Rebased on top of latest next

@glennmatthews assuming what @lampwins is asking for just doesn't work out what's the next steps to get this to land?

@bryanculver
Copy link
Member

Good news everyone! I figured out the issue.

The timestamp being searched for is being considered an invalid timestamp.

Changed the test to:

...
    def test_expires(self):
        params = {"expires": "3000-01-01T00:00:00"}
        fs = self.filterset(params, self.queryset)
        print(fs.errors)
        self.assertEqual(fs.qs.count(), 2)

Which returned:

======================================================================
FAIL: test_expires (nautobot.users.tests.test_filters.TokenTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/source/nautobot/users/tests/test_filters.py", line 234, in test_expires
    self.assertEqual(fs.qs.count(), 2)
AssertionError: 3 != 2

Stdout:
<ul class="errorlist"><li>expires<ul class="errorlist"><li>Enter a valid date/time.</li></ul></li></ul>

This is invalid because the auto-generation from BaseFilterSet is creating a the filter as a MultipleChoiceFilter of DateTime type:
https://github.com/nautobot/nautobot/blob/develop/nautobot/utilities/filters.py#L223

This means the filter set will expect these as an array instead:

...
    def test_expires(self):
        params = {"expires": ["3000-01-01 00:00:00"]}
        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
        params = {"expires__gte": ["2021-01-01 00:00:00"]}
        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
        params = {"expires__lte": ["2021-01-01 00:00:00"]}
        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)

Additionally, it's expecting a date/time format of YYYY-MM-DD HH:MM:ss (or similar: https://github.com/django/django/blob/3.2/django/db/models/fields/__init__.py#L1289).

Result:

➜ invoke unittest -l nautobot/users
Running docker-compose command "ps --services --filter status=running"
Running docker-compose command "run --entrypoint 'coverage run --module nautobot.core.cli --config=nautobot/core/tests/nautobot_config.py test nautobot/users --buffer' nautobot"
[+] Running 3/0
 ⠿ Container nautobot-selenium-1  Running                                                                                                              0.0s
 ⠿ Container nautobot-redis-1     Running                                                                                                              0.0s
 ⠿ Container nautobot-postgres-1  Running                                                                                                              0.0s
Creating test database for alias 'default'...

System check identified some issues:

WARNINGS:
<Settings "nautobot_config">: (nautobot.core.W005) STORAGE_CONFIG has been set but STORAGE_BACKEND is not defined. STORAGE_CONFIG will be ignored.

System check identified 1 issue (0 silenced).
...s...............s....................s.......................s......s...............s..........................
----------------------------------------------------------------------
Ran 114 tests in 37.379s

OK (skipped=6)

Full Diff:

diff --git a/nautobot/users/filters.py b/nautobot/users/filters.py
index 773262149..e066c50f9 100644
--- a/nautobot/users/filters.py
+++ b/nautobot/users/filters.py
@@ -85,16 +85,10 @@ class TokenFilterSet(BaseFilterSet):
         to_field_name="username",
         label="User (name)",
     )
-    created = django_filters.DateTimeFilter()
-    created__gte = django_filters.DateTimeFilter(field_name="created", lookup_expr="gte")
-    created__lte = django_filters.DateTimeFilter(field_name="created", lookup_expr="lte")
-    expires = django_filters.DateTimeFilter()
-    expires__gte = django_filters.DateTimeFilter(field_name="expires", lookup_expr="gte")
-    expires__lte = django_filters.DateTimeFilter(field_name="expires", lookup_expr="lte")
 
     class Meta:
         model = Token
-        fields = ["id", "key", "write_enabled"]
+        fields = ["id", "key", "write_enabled", "created", "expires"]
 
 
 class ObjectPermissionFilterSet(BaseFilterSet):
diff --git a/nautobot/users/tests/test_filters.py b/nautobot/users/tests/test_filters.py
index f10c250f4..4ee14dfa4 100644
--- a/nautobot/users/tests/test_filters.py
+++ b/nautobot/users/tests/test_filters.py
@@ -228,11 +228,11 @@ class TokenTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
     def test_expires(self):
-        params = {"expires": "3000-01-01T00:00:00"}
+        params = {"expires": ["3000-01-01 00:00:00"]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-        params = {"expires__gte": "2021-01-01T00:00:00"}
+        params = {"expires__gte": ["2021-01-01 00:00:00"]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-        params = {"expires__lte": "2021-01-01T00:00:00"}
+        params = {"expires__lte": ["2021-01-01 00:00:00"]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
     def test_key(self):

@imranh2
Copy link
Contributor Author

imranh2 commented Mar 5, 2022

@bryanculver great find! 🙂

I wonder what the correct way to document this is, not only for end users of this but also Devs in general as using the auto-generated for datetime related stuff gives you something that isn't a drop in replacement of django_filters.DateTimeFilter()

Unless there's already a note somewhere I missed...

@bryanculver
Copy link
Member

@imranh2 I couldn't push to your fork to update this PR. If you don't mind implementing the changes and handling the merge conflict we can do a swift re-review and get this feature out soon 😄

Regarding updating developer documentation, I'll add it as a housekeeping item to address it.

@imranh2
Copy link
Contributor Author

imranh2 commented Mar 5, 2022

@bryanculver sweet, can apply your diff and sort out any conflicts :)

Will aim to do so within the next 24 hours hopefully

@imranh2
Copy link
Contributor Author

imranh2 commented Mar 6, 2022

Applied the diff from @bryanculver (thanks again)

Also rebased on top of the latest version of next.

Testing green on my end.

@imranh2
Copy link
Contributor Author

imranh2 commented Mar 16, 2022

Latest changes are just accepting @bryanculver suggestion to remove super users from seeing peoples tokens. Also a rebase on top of the latest next branch.

Copy link
Member

@bryanculver bryanculver left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do the user filtering options on TokenFilterSet still need to exist?

queryset=get_user_model().objects.all(),
label="User",
)
user = django_filters.ModelMultipleChoiceFilter(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I was reviewing the test criteria, since we are removing the ability for superusers to see all user API tokens, I don't know if this will do anything anymore. @glennmatthews Thoughts on leaving this is for potential future uses?

The TokenViewSet as implemented will already do user filtering for non-superusers.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you're right - we could probably:

  • Remove user_id and user from TokenFilterSet
  • Remove user from TokenSerializer
  • Update the example in authentication.md accordingly.

Copy link
Contributor

@jathanism jathanism left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please address the hard-coded authenticate() inside of the view's post() method and this will be good to go! :)

nautobot/users/api/views.py Outdated Show resolved Hide resolved
@imranh2
Copy link
Contributor Author

imranh2 commented Mar 18, 2022

@imranh2 imranh2 requested a review from jathanism March 18, 2022 19:34
nautobot/users/api/views.py Outdated Show resolved Hide resolved
nautobot/users/tests/test_api.py Outdated Show resolved Hide resolved
@imranh2
Copy link
Contributor Author

imranh2 commented Mar 22, 2022

Latest requested changes incorporated.

Testing green on my end.

Copy link
Contributor

@glennmatthews glennmatthews left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for continuing to see this through - sorry it's taking so many iterations!

Comment on lines +95 to +105
self.create_data = [
{
"user": self.user.pk,
},
{
"user": self.user.pk,
},
{
"user": self.user.pk,
},
]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On re-review I actually have some uncertainty here - would you mind adding a few more API test cases for safety:

  • User A tries to create a token belonging to User B (should fail with an appropriate error)
  • User B queries for a list of tokens (confirm that User A's tokens do not appear in the list)
  • User A tries to update their own token so that it refers to User B as the owner (should fail with an appropriate error)

nautobot/users/filters.py Outdated Show resolved Hide resolved
nautobot/users/api/serializers.py Outdated Show resolved Hide resolved
nautobot/docs/rest-api/authentication.md Outdated Show resolved Hide resolved
nautobot/users/tests/test_filters.py Outdated Show resolved Hide resolved
@imranh2
Copy link
Contributor Author

imranh2 commented Mar 22, 2022

Added @bryanculver suggestions.

Can't figure out how to get tests working for @glennmatthews suggestions, I left a commented out test in nautobot/users/tests/test_api.py called test_provision_token_diff_user(), struggling with getting the URL part url = reverse("users-api:tokens") not it...

nautobot/docs/rest-api/authentication.md Outdated Show resolved Hide resolved
nautobot/docs/release-notes/version-1.3.md Outdated Show resolved Hide resolved

class Meta:
model = Token
fields = ("id", "url", "display", "user", "created", "expires", "key", "write_enabled", "description")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
fields = ("id", "url", "display", "user", "created", "expires", "key", "write_enabled", "description")
fields = ("id", "url", "display", "created", "expires", "key", "write_enabled", "description")

Copy link
Contributor Author

@imranh2 imranh2 Mar 23, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doing that causes:

======================================================================
FAIL: test_create_object (nautobot.users.tests.test_api.TokenTest)
POST a single object with permission.
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/source/nautobot/utilities/testing/api.py", line 340, in test_create_object
    self.assertHttpStatus(response, status.HTTP_201_CREATED)
  File "/source/nautobot/utilities/testing/views.py", line 150, in assertHttpStatus
    self.assertEqual(response.status_code, expected_status, err_message)
AssertionError: 400 != 201 : Expected HTTP status 201; received 400: {'user': [ErrorDetail(string='This field cannot be null.', code='null')]}

----------------------------------------------------------------------

Trying to work out where this dependency for user is coming from... If you know, please shout!

Comment on lines +95 to +105
self.create_data = [
{
"user": self.user.pk,
},
{
"user": self.user.pk,
},
{
"user": self.user.pk,
},
]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the crossed wires here - since addressing Bryan's comments about removing the user field from the serializer entirely, my first and third suggestions are no longer applicable. I'll offer a suggested implementation below.

Comment on lines 156 to 190
# def test_provision_token_diff_user(self):
# """
# Test the behavior of the token provisioning view when a different user is supplied.
# """
# # Create a user and token
# data = {
# "username": "user1",
# "password": "abc123",
# }
# user1 = User.objects.create_user(**data)
# Token(user=user1).save()
# token = Token.objects.get(user=user1)
# # create a 2nd user we'll try to make a token for
# data = {
# "username": "user2",
# "password": "password",
# }
# user2 = User.objects.create_user(**data)

# header = {
# "Authorization:": "Token " + token.key,
# }
# data = {
# "user": user2.id.__str__(),
# "expires": "2100-01-01T00:00:00.000Z",
# "key": "abcdefghijklmnopqrstubwxyzabcdefghijklmnopqrst",
# "write_enabled": "true",
# "description": "string"
# }
# print(type(user2.id))
# print(data)
# url = reverse("users-api:tokens")

# response = self.client.post(url, header=header, data=data)
# self.assertEqual(response.status_code, 403)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# def test_provision_token_diff_user(self):
# """
# Test the behavior of the token provisioning view when a different user is supplied.
# """
# # Create a user and token
# data = {
# "username": "user1",
# "password": "abc123",
# }
# user1 = User.objects.create_user(**data)
# Token(user=user1).save()
# token = Token.objects.get(user=user1)
# # create a 2nd user we'll try to make a token for
# data = {
# "username": "user2",
# "password": "password",
# }
# user2 = User.objects.create_user(**data)
# header = {
# "Authorization:": "Token " + token.key,
# }
# data = {
# "user": user2.id.__str__(),
# "expires": "2100-01-01T00:00:00.000Z",
# "key": "abcdefghijklmnopqrstubwxyzabcdefghijklmnopqrst",
# "write_enabled": "true",
# "description": "string"
# }
# print(type(user2.id))
# print(data)
# url = reverse("users-api:tokens")
# response = self.client.post(url, header=header, data=data)
# self.assertEqual(response.status_code, 403)
def test_tokens_are_restricted_by_user(self):
"""
Test that the tokens API can only access tokens belonging to the authenticated user.
"""
# Create a user and token
data = {
"username": "user1",
"password": "abc123",
}
self.user = User.objects.create_user(**data)
Token(user=self.user).save()
token = Token.objects.get(user=self.user)
self.header = {
"HTTP_AUTHORIZATION:": "Token " + token.key,
}
# List all tokens available to user1
self.add_permissions("users.view_token")
response = self.client.get(self._get_list_url(), **self.header)
# Assert that only the user1_token appears in the results
self.assertEqual(len(response.data["results"]), 1)
self.assertEqual(response.data["results"][0]["id"], token.id)
# Try to retrieve a token belonging to another user by ID
response = self.client.get(self._get_detail_url(Token.objects.filter(user__n=self.user).first()), **self.header)
self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@glennmatthews Might be missing something in your suggestion, but where do you create a token by another user? As I read the suggestion you might be getting the 404 because no other tokens exist.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the base APITestCase.setUp(), one token is created for the default testuser, and in this test-case's setUp() two more tokens are created for the default test user. In my suggested test case here we then switch to a different user account (overwriting self.user because self.add_permissions() is much easier to use that way).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added this, couldn't get the user__n thing working (django.core.exceptions.FieldError: Related Field got invalid lookup: n) so just created a 2nd user.

Comment on lines +96 to +104
{
"user": self.user.pk,
},
{
"user": self.user.pk,
},
{
"user": self.user.pk,
},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since user is no longer a serializer field, these inputs will IIRC just be discarded. Perhaps:

Suggested change
{
"user": self.user.pk,
},
{
"user": self.user.pk,
},
{
"user": self.user.pk,
},
{
"key": "a" * 40,
},
{
"key": "b" * 40,
},
{
"key": "c" * 40,
},

Copy link
Contributor

@jathanism jathanism left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor suggestions to remove more code duplication. :)

nautobot/users/api/views.py Outdated Show resolved Hide resolved
nautobot/users/api/urls.py Outdated Show resolved Hide resolved
nautobot/users/api/urls.py Outdated Show resolved Hide resolved
nautobot/users/api/views.py Outdated Show resolved Hide resolved
@bryanculver bryanculver self-assigned this Mar 24, 2022
Patch sets:
* netbox-community/netbox#6592
* netbox-community/netbox@34aa231

author: jeremystretch <jstretch@ns1.com>
@imranh2
Copy link
Contributor Author

imranh2 commented Mar 25, 2022

Added @jathanism suggested changes, thanks again for the good spot!

Still trying (and failing) to remove users from the token serializer field

@bryanculver
Copy link
Member

Changed some aspects of the implementation onto a local branch. @imranh2 credited in commits and description of #1551

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
type: feature Introduction of new or enhanced functionality to the application
Projects
No open projects
Archived in project
Development

Successfully merging this pull request may close these issues.

API provision tokens for user
5 participants