Skip to content

Commit 5508303

Browse files
authored
Use project-scoped temporal tokens to interact with the API from the builders (#10378)
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 readthedocs/meta#21
1 parent 7f643d9 commit 5508303

23 files changed

+651
-68
lines changed

readthedocs/api/v2/admin.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from django.contrib import admin
2+
from rest_framework_api_key.admin import APIKeyModelAdmin
3+
4+
from readthedocs.api.v2.models import BuildAPIKey
5+
6+
7+
@admin.register(BuildAPIKey)
8+
class BuildAPIKeyAdmin(APIKeyModelAdmin):
9+
raw_id_fields = ["project"]
10+
search_fields = [*APIKeyModelAdmin.search_fields, "project__slug"]

readthedocs/api/v2/apps.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from django.apps import AppConfig
2+
3+
4+
class APIV2Config(AppConfig):
5+
name = "readthedocs.api.v2"
6+
verbose_name = "API V2"

readthedocs/api/v2/client.py

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ def dumps(self, data):
2323
return JSONRenderer().render(data)
2424

2525

26-
def setup_api():
26+
def setup_api(build_api_key):
2727
session = requests.Session()
2828
if settings.SLUMBER_API_HOST.startswith('https'):
2929
# Only use the HostHeaderSSLAdapter for HTTPS connections
@@ -48,7 +48,8 @@ def setup_api():
4848
settings.SLUMBER_API_HOST,
4949
adapter_class(max_retries=retry),
5050
)
51-
session.headers.update({'Host': settings.PRODUCTION_DOMAIN})
51+
session.headers.update({"Host": settings.PRODUCTION_DOMAIN})
52+
session.headers["Authorization"] = f"Token {build_api_key}"
5253
api_config = {
5354
'base_url': '%s/api/v2/' % settings.SLUMBER_API_HOST,
5455
'serializer': serialize.Serializer(
@@ -60,13 +61,4 @@ def setup_api():
6061
),
6162
'session': session,
6263
}
63-
if settings.SLUMBER_USERNAME and settings.SLUMBER_PASSWORD:
64-
log.debug(
65-
'Using slumber v2.',
66-
username=settings.SLUMBER_USERNAME,
67-
api_host=settings.SLUMBER_API_HOST,
68-
)
69-
session.auth = (settings.SLUMBER_USERNAME, settings.SLUMBER_PASSWORD)
70-
else:
71-
log.warning('SLUMBER_USERNAME/PASSWORD settings are not set')
7264
return API(**api_config)
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# Generated by Django 3.2.18 on 2023-05-31 20:40
2+
3+
import django.db.models.deletion
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
initial = True
9+
10+
dependencies = [
11+
("projects", "0100_project_readthedocs_yaml_path"),
12+
]
13+
14+
operations = [
15+
migrations.CreateModel(
16+
name="BuildAPIKey",
17+
fields=[
18+
(
19+
"id",
20+
models.CharField(
21+
editable=False,
22+
max_length=150,
23+
primary_key=True,
24+
serialize=False,
25+
unique=True,
26+
),
27+
),
28+
("prefix", models.CharField(editable=False, max_length=8, unique=True)),
29+
("hashed_key", models.CharField(editable=False, max_length=150)),
30+
("created", models.DateTimeField(auto_now_add=True, db_index=True)),
31+
(
32+
"name",
33+
models.CharField(
34+
default=None,
35+
help_text="A free-form name for the API key. Need not be unique. 50 characters max.",
36+
max_length=50,
37+
),
38+
),
39+
(
40+
"revoked",
41+
models.BooleanField(
42+
blank=True,
43+
default=False,
44+
help_text="If the API key is revoked, clients cannot use it anymore. (This cannot be undone.)",
45+
),
46+
),
47+
(
48+
"expiry_date",
49+
models.DateTimeField(
50+
blank=True,
51+
help_text="Once API key expires, clients cannot use it anymore.",
52+
null=True,
53+
verbose_name="Expires",
54+
),
55+
),
56+
(
57+
"project",
58+
models.ForeignKey(
59+
help_text="Project that this API key grants access to",
60+
on_delete=django.db.models.deletion.CASCADE,
61+
related_name="build_api_keys",
62+
to="projects.project",
63+
),
64+
),
65+
],
66+
options={
67+
"verbose_name": "Build API key",
68+
"verbose_name_plural": "Build API keys",
69+
"ordering": ("-created",),
70+
"abstract": False,
71+
},
72+
),
73+
]

readthedocs/api/v2/migrations/__init__.py

Whitespace-only changes.

readthedocs/api/v2/models.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
from datetime import timedelta
2+
3+
from django.db import models
4+
from django.utils import timezone
5+
from django.utils.translation import gettext_lazy as _
6+
from rest_framework_api_key.models import AbstractAPIKey, BaseAPIKeyManager
7+
8+
from readthedocs.projects.models import Project
9+
10+
11+
class BuildAPIKeyManager(BaseAPIKeyManager):
12+
def create_key(self, project):
13+
"""
14+
Create a new API key for a project.
15+
16+
Build API keys are valid for 3 hours,
17+
and can be revoked at any time by hitting the /api/v2/revoke/ endpoint.
18+
"""
19+
expiry_date = timezone.now() + timedelta(hours=3)
20+
return super().create_key(
21+
# Name is required, so we use the project slug for it.
22+
name=project.slug,
23+
expiry_date=expiry_date,
24+
project=project,
25+
)
26+
27+
28+
class BuildAPIKey(AbstractAPIKey):
29+
30+
"""
31+
API key for securely interacting with the API from the builders.
32+
33+
The key is attached to a single project,
34+
it can be used to have write access to the API V2.
35+
"""
36+
37+
project = models.ForeignKey(
38+
Project,
39+
on_delete=models.CASCADE,
40+
related_name="build_api_keys",
41+
help_text=_("Project that this API key grants access to"),
42+
)
43+
44+
objects = BuildAPIKeyManager()
45+
46+
class Meta(AbstractAPIKey.Meta):
47+
verbose_name = _("Build API key")
48+
verbose_name_plural = _("Build API keys")

readthedocs/api/v2/permissions.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
"""Defines access permissions for the API."""
22

33
from rest_framework import permissions
4+
from rest_framework_api_key.permissions import BaseHasAPIKey, KeyParser
45

6+
from readthedocs.api.v2.models import BuildAPIKey
57
from readthedocs.builds.models import Version
68

79

@@ -65,3 +67,37 @@ def has_permission(self, request, view):
6567
.exists()
6668
)
6769
return has_access
70+
71+
72+
class TokenKeyParser(KeyParser):
73+
74+
"""
75+
Custom key parser to use ``Token {TOKEN}`` as format.
76+
77+
This is the same format we use in API V3 for auth/authz.
78+
"""
79+
80+
keyword = "Token"
81+
82+
83+
class HasBuildAPIKey(BaseHasAPIKey):
84+
85+
"""
86+
Custom permission to inject the build API key into the request.
87+
88+
This avoids having to parse the key again on each view.
89+
The key is injected in the ``request.build_api_key`` attribute
90+
only if it's valid, otherwise it's set to ``None``.
91+
"""
92+
93+
model = BuildAPIKey
94+
key_parser = TokenKeyParser()
95+
96+
def has_permission(self, request, view):
97+
build_api_key = None
98+
has_permission = super().has_permission(request, view)
99+
if has_permission:
100+
key = self.get_key(request)
101+
build_api_key = self.model.objects.get_from_key(key)
102+
request.build_api_key = build_api_key
103+
return has_permission

readthedocs/api/v2/urls.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,14 @@
4545
re_path(r'^', include(router.urls)),
4646
]
4747

48+
urlpatterns += [
49+
path(
50+
"revoke/",
51+
core_views.RevokeBuildAPIKeyView.as_view(),
52+
name="revoke_build_api_key",
53+
),
54+
]
55+
4856
function_urls = [
4957
path("docurl/", core_views.docurl, name="docurl"),
5058
path("footer_html/", footer_views.FooterHTML.as_view(), name="footer_html"),

readthedocs/api/v2/views/core_views.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,37 @@
11
"""Utility endpoints relating to canonical urls, embedded content, etc."""
2-
32
from django.shortcuts import get_object_or_404
43
from rest_framework import decorators, permissions, status
54
from rest_framework.renderers import JSONRenderer
65
from rest_framework.response import Response
6+
from rest_framework.views import APIView
77

8+
from readthedocs.api.v2.permissions import HasBuildAPIKey
89
from readthedocs.builds.constants import LATEST
910
from readthedocs.builds.models import Version
1011
from readthedocs.core.templatetags.core_tags import make_document_url
1112
from readthedocs.projects.models import Project
1213

1314

15+
class RevokeBuildAPIKeyView(APIView):
16+
17+
"""
18+
Revoke a build API key.
19+
20+
This is done by hitting the /api/v2/revoke/ endpoint with a POST request,
21+
while using the API key to be revoked as the authorization key.
22+
"""
23+
24+
http_method_names = ["post"]
25+
permission_classes = [HasBuildAPIKey]
26+
renderer_classes = [JSONRenderer]
27+
28+
def post(self, request, *args, **kwargs):
29+
build_api_key = request.build_api_key
30+
build_api_key.revoked = True
31+
build_api_key.save()
32+
return Response(status=status.HTTP_204_NO_CONTENT)
33+
34+
1435
@decorators.api_view(['GET'])
1536
@decorators.permission_classes((permissions.AllowAny,))
1637
@decorators.renderer_classes((JSONRenderer,))

0 commit comments

Comments
 (0)