Skip to content

Use project-scoped temporal tokens to interact with the API from the builders #10378

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

Merged
merged 25 commits into from
Jun 22, 2023

Conversation

stsewd
Copy link
Member

@stsewd stsewd commented Jun 1, 2023

This implements the design document from https://dev.readthedocs.io/en/latest/design/secure-api-access-from-builders.html

  • The api.v2 package was converted into a real django app, so we can add models to it.
  • A custom API key model was created to hold the relationship of the key with a project
  • A /api/v2/revoke/ endpoint was added to revoke an API key after it has been used.
  • The old super-user permission based still works, this is to avoid breaking the builds while we do the deploy,
    that code can be removed in the next deploy.
  • All endpoints use the project attached to the API key to filter the resources
  • API keys expire after 3 hours

Closes https://github.com/readthedocs/meta/issues/21

stsewd added 7 commits May 31, 2023 20:00
For #10289
we are going to need to pass a new argument (build_api_key).
And since we deploy webs first,
builders will have the old task that doesn't match the new signature,
and the task will fail.

To avoid this, we can just accept any kwargs,
this obviously only works if the change is backwards compatible
with the old code from the builders (in this case it will be).
We aren't using the LocalEnvironment class,
only the BuildEnvironment, so there is no need to keep
BaseEnvironment seperated from BuildEnvironment.
stsewd added a commit that referenced this pull request Jun 5, 2023
With #10378
we now need to always pass an environment,
we can't just create a default one.
stsewd added a commit that referenced this pull request Jun 5, 2023
With #10378
we now need to always pass an environment,
we can't just create a default one.
@stsewd stsewd changed the title Build api token access Use project-scoped temporal tokens to interact with the API from the builders Jun 15, 2023
@stsewd stsewd marked this pull request as ready for review June 16, 2023 03:26
@stsewd stsewd requested a review from a team as a code owner June 16, 2023 03:26
@stsewd stsewd requested a review from humitos June 16, 2023 03:26
Copy link
Member

@ericholscher ericholscher left a comment

Choose a reason for hiding this comment

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

This looks like a good start, I think there's some places where we need to be more clear about how auth is done in comments and perhaps dev docs somewhere? I don't fully understand where the actual flow of auth is getting checked, likely because some of that logic is living in the API key package? But how do we easily explain to readers of our code what it's depending on the API key package for?

@@ -65,3 +67,37 @@ def has_permission(self, request, view):
.exists()
)
return has_access


class TokenKeyParser(KeyParser):
Copy link
Member

Choose a reason for hiding this comment

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

Odd this isn't builtin... seems pretty standard?

Copy link
Member Author

Choose a reason for hiding this comment

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

They default to Api-Key {token}

@@ -151,7 +155,7 @@ class ProjectViewSet(DisableListEndpoint, UpdateModelMixin, UserSelectViewSet):

"""List, filter, etc, Projects."""

permission_classes = [APIRestrictedPermission]
permission_classes = [HasBuildAPIKey | APIRestrictedPermission]
Copy link
Member

Choose a reason for hiding this comment

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

Weird syntax... I feel like this could use a comment explaining the logic. Guessing it means either can be used to validate permissions (eg. OR)?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah, this is an or (it supports & too), it's standard from REST framework. I can add a comment if it's useful.

return Response({
'url': project.get_docs_url(),
})

def get_queryset(self):
Copy link
Member

Choose a reason for hiding this comment

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

Could use a docstring, since it seems like this is the core of the logic that checks authz in the queryset?

build_api_key = request.build_api_key
if build_api_key:
if project_slug != build_api_key.project.slug:
raise Http404()
Copy link
Member

@ericholscher ericholscher Jun 19, 2023

Choose a reason for hiding this comment

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

We should probably have a log message here, at least for our debugging use.

Why isn't this check performed in other places, because the queryset filter is doing the work there, and this code isn't using the get_queryset method for some reason?

Copy link
Member Author

Choose a reason for hiding this comment

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

yeah, all others just use the queryset.


def has_permission(self, request, view):
build_api_key = None
has_permission = super().has_permission(request, view)
Copy link
Member

Choose a reason for hiding this comment

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

I don't fully understand what code this is calling... What is actually being validated here? Is it in our code, or a third-party package? And is it checking the project matches the API key somehow?

Copy link
Member Author

Choose a reason for hiding this comment

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

We rely on the permission class from the package to make sure that the API key given is valid, we just override it to attach the key to the request, otherwise we need to parse the key and get it from the db every time we want to use from a view.

"""
Custom permission to inject the build API key into the request.
This avoids having to parse the key again on each view.
The key is injected in the ``request.build_api_key`` attribute
only if it's valid, otherwise it's set to ``None``.
"""

Copy link
Member

Choose a reason for hiding this comment

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

Gotcha, so it's basically just checking that the key exists? Not checking it against any specific user or anything.

Copy link
Member Author

Choose a reason for hiding this comment

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

yeah, api keys aren't attached to a user, authorization for a given project is done via the queryset.

Copy link
Member

@ericholscher ericholscher left a comment

Choose a reason for hiding this comment

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

Think I'm 👍 on this after the comments. Looks like a huge win.

@stsewd stsewd merged commit 5508303 into main Jun 22, 2023
@stsewd stsewd deleted the build-api-token-access branch June 22, 2023 23:24
Copy link
Member

@humitos humitos left a comment

Choose a reason for hiding this comment

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

This is really cool! I know this is already in production, but I haven't had the time to take a look before. I made some comments and questions that can be applied in another PR if you consider


project = models.ForeignKey(
Project,
on_delete=models.CASCADE,
Copy link
Member

Choose a reason for hiding this comment

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

This adds more overhead to the deletion of the project. We may want to make this SET_NULL or similar. See #10040

Comment on lines +339 to +347
def perform_create(self, serializer):
"""Restrict creation to builds attached to the project from the api key."""
build_pk = serializer.validated_data["build"].pk
api_key = self.request.build_api_key
if api_key and not api_key.project.builds.filter(pk=build_pk).exists():
raise PermissionDenied()
# If the request isn't attached to a build api key,
# the user doing the request is a superuser, so it has access to all projects.
return super().perform_create(serializer)
Copy link
Member

Choose a reason for hiding this comment

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

Why do we need this only for this view but it's not required when updating a Version or Build object via the API from the builder as well?

Copy link
Member Author

Choose a reason for hiding this comment

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

When updating, we know the object (since it already exists), so the queryset already filters the objects the token has access to. When creating, we don't know where the object belongs since it hasn't been created, but we know that it should be attached to the project the token grants access to.

try:
self.data.api_client.revoke.post()
except Exception:
log.exception("Failed to revoke build api key.", exc_info=True)
Copy link
Member

Choose a reason for hiding this comment

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

I would make this just an INFO. There is no need to log this as an exception. The key will expire anyways in 3 hours.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants