Skip to content

Commit 829f242

Browse files
authored
Build: use scoped credentials for interacting with S3 (#12078)
The main difference from the other credentials, is that we now need to pass an additional "sesstion_token" value (which django storages calls security_token :_) This also brings a little more of things to do if you want to actually test it locally, as there isn't an STS service for minio. We need to create a role with the proper policy for production as described in the dev docs, I guess we need to that from the ops repos? Not sure how to translate that to salt/terraform, I was able to create things for dev using the UI. And, there may be a little more of work involved to remove the storage credentials from the builder settings, since builders are basically a full working django app, it's expecting to have access to static resources somewhere... dealing with settings :_ Closes readthedocs/readthedocs-ops#1599
1 parent 41c286d commit 829f242

File tree

20 files changed

+1149
-24
lines changed

20 files changed

+1149
-24
lines changed
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
AWS temporary credentials
2+
=========================
3+
4+
Builders run arbitrary commands provided by the user, while we run the commands in a sandboxed environment (docker),
5+
that shouln't be the only line of defense, as we still interact with the files generated by the user outside docker for some operations.
6+
7+
This is why instead of using credentials that have access to all the resources in AWS,
8+
we are using credentials that are generated by the `AWS STS service <https://docs.aws.amazon.com/STS/latest/APIReference/welcome.html>`__,
9+
which are temporary and scoped to the resources that are needed for the build.
10+
11+
Local development
12+
-----------------
13+
14+
In order to make use of STS, you need:
15+
16+
- Create a role in IAM with a trusted entity type set to the AWS account that is going to be used to generate the temporary credentials.
17+
- Create an inline policy for the role, the policy should allow access to all S3 buckets and paths that are going to be used.
18+
- Create an inline policy to the user that is going to be used to generate the temporary credentials,
19+
the policy should allow the ``sts:AssumeRole`` action for the role created in the previous step.
20+
21+
You can use :ref:`environment variables <settings:AWS configuration>` to set the credentials for AWS, make sure to set the value of ``RTD_S3_PROVIDER`` to ``AWS``.
22+
23+
.. note::
24+
25+
If you are part of the development team, you should be able to use the credentials from the ``storage-dev``` user,
26+
which is already configured to make use of STS, and the ARN from the ``RTDSTSAssumeRoleDev`` role.
27+
28+
.. note::
29+
30+
You should use AWS only when you are testing the AWS integration,
31+
use the default minio provider for local development.
32+
Otherwise, files may be overridden if multiple developers are using the same credentials.

docs/dev/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ or taking the open source Read the Docs codebase for your own custom installatio
2828
migrations
2929
server-side-search
3030
search-integration
31+
aws-temporary-credentials
3132
subscriptions
3233
github-app
3334
settings

docs/dev/settings.rst

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,22 @@ providers using the following environment variables:
167167
.. envvar:: RTD_SOCIALACCOUNT_PROVIDERS_GOOGLE_CLIENT_ID
168168
.. envvar:: RTD_SOCIALACCOUNT_PROVIDERS_GOOGLE_SECRET
169169

170+
AWS configuration
171+
~~~~~~~~~~~~~~~~~
172+
173+
The following variables can be used to use AWS in your local environment.
174+
Useful for testing :doc:`temporary credentials </aws-temporary-credentials>`.
175+
176+
.. envvar:: RTD_S3_PROVIDER
177+
.. envvar:: RTD_AWS_ACCESS_KEY_ID
178+
.. envvar:: RTD_AWS_SECRET_ACCESS_KEY
179+
.. envvar:: RTD_AWS_STS_ASSUME_ROLE_ARN
180+
.. envvar:: RTD_S3_MEDIA_STORAGE_BUCKET
181+
.. envvar:: RTD_S3_BUILD_COMMANDS_STORAGE_BUCKET
182+
.. envvar:: RTD_S3_BUILD_TOOLS_STORAGE_BUCKET
183+
.. envvar:: RTD_S3_STATIC_STORAGE_BUCKET
184+
.. envvar:: RTD_AWS_S3_REGION_NAME
185+
170186
GitHub App
171187
~~~~~~~~~~
172188

readthedocs/api/v2/views/model_views.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Endpoints for listing Projects, Versions, Builds, etc."""
22

33
import json
4+
from dataclasses import asdict
45

56
import structlog
67
from allauth.socialaccount.models import SocialAccount
@@ -27,6 +28,9 @@
2728
from readthedocs.api.v2.permissions import IsOwner
2829
from readthedocs.api.v2.permissions import ReadOnlyPermission
2930
from readthedocs.api.v2.utils import normalize_build_command
31+
from readthedocs.aws.security_token_service import AWSTemporaryCredentialsError
32+
from readthedocs.aws.security_token_service import get_s3_build_media_scoped_credentials
33+
from readthedocs.aws.security_token_service import get_s3_build_tools_scoped_credentials
3034
from readthedocs.builds.constants import INTERNAL
3135
from readthedocs.builds.models import Build
3236
from readthedocs.builds.models import BuildCommandResult
@@ -345,6 +349,44 @@ def reset(self, request, **kwargs):
345349
def get_queryset_for_api_key(self, api_key):
346350
return self.model.objects.filter(project=api_key.project)
347351

352+
@decorators.action(
353+
detail=True,
354+
permission_classes=[HasBuildAPIKey],
355+
methods=["post"],
356+
url_path="credentials/storage",
357+
)
358+
def credentials_for_storage(self, request, **kwargs):
359+
"""
360+
Generate temporary credentials for interacting with storage.
361+
362+
This can generate temporary credentials for interacting with S3 only for now.
363+
"""
364+
build = self.get_object()
365+
credentials_type = request.data.get("type")
366+
367+
if credentials_type == "build_media":
368+
method = get_s3_build_media_scoped_credentials
369+
# 30 minutes should be enough for uploading build artifacts.
370+
duration = 30 * 60
371+
elif credentials_type == "build_tools":
372+
method = get_s3_build_tools_scoped_credentials
373+
# 30 minutes should be enough for downloading build tools.
374+
duration = 30 * 60
375+
else:
376+
return Response(
377+
{"error": "Invalid storage type"},
378+
status=status.HTTP_400_BAD_REQUEST,
379+
)
380+
381+
try:
382+
credentials = method(build=build, duration=duration)
383+
except AWSTemporaryCredentialsError:
384+
return Response(
385+
{"error": "Failed to generate temporary credentials"},
386+
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
387+
)
388+
return Response({"s3": asdict(credentials)})
389+
348390

349391
class BuildCommandViewSet(DisableListEndpoint, CreateModelMixin, UserSelectViewSet):
350392
parser_classes = [JSONParser, MultiPartParser]

readthedocs/aws/__init__.py

Whitespace-only changes.
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
"""
2+
Module to interact with AWS STS (Security Token Service) to assume a role and get temporary scoped credentials.
3+
4+
This is mainly used to generate temporary credentials to interact with S3 buckets from the builders.
5+
6+
In order to make use of STS, we need:
7+
8+
- Create a role in IAM with a trusted entity type set to the AWS account that is going to be used to generate the temporary credentials.
9+
- Create an inline policy for the role, the policy should allow access to all S3 buckets and paths that are going to be used.
10+
- Create an inline policy to the user that is going to be used to generate the temporary credentials,
11+
the policy should allow the ``sts:AssumeRole`` action for the role created in the previous step.
12+
13+
The permissions of the temporary credentials are the result of the intersection of the role policy and the inline policy that is passed to the AssumeRole API.
14+
This means that the inline policy can be used to limit the permissions of the temporary credentials, but not to expand them.
15+
16+
See:
17+
18+
- https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html
19+
- https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_sts-comparison.html
20+
- https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_temp_control-access_assumerole.html
21+
- https://docs.readthedocs.com/dev/latest/aws-temporary-credentials.html
22+
"""
23+
24+
import json
25+
from dataclasses import dataclass
26+
27+
import boto3
28+
import structlog
29+
from django.conf import settings
30+
31+
32+
log = structlog.get_logger(__name__)
33+
34+
35+
class AWSTemporaryCredentialsError(Exception):
36+
"""Exception raised when there is an error getting AWS S3 credentials."""
37+
38+
39+
@dataclass
40+
class AWSTemporaryCredentials:
41+
"""Dataclass to hold AWS temporary credentials."""
42+
43+
access_key_id: str
44+
secret_access_key: str
45+
session_token: str | None
46+
47+
48+
@dataclass
49+
class AWSS3TemporaryCredentials(AWSTemporaryCredentials):
50+
"""Subclass of AWSTemporaryCredentials to include S3 specific fields."""
51+
52+
bucket_name: str
53+
region_name: str
54+
55+
56+
def get_sts_client():
57+
return boto3.client(
58+
"sts",
59+
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
60+
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
61+
region_name=settings.AWS_S3_REGION_NAME,
62+
)
63+
64+
65+
def _get_scoped_credentials(*, session_name, policy, duration) -> AWSTemporaryCredentials:
66+
"""
67+
:param session_name: An identifier to attach to the generated credentials, useful to identify who requested them.
68+
AWS limits the session name to 64 characters, so if the session_name is too long, it will be truncated.
69+
:param duration: The duration of the credentials in seconds. Default is 15 minutes.
70+
Note that the minimum duration time is 15 minutes and the maximum is given by the role (defaults to 1 hour).
71+
:param policy: The inline policy to attach to the generated credentials.
72+
73+
.. note::
74+
75+
If USING_AWS is set to False, this function will return
76+
the values of the AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY settings.
77+
Useful for local development where we don't have a service like AWS STS.
78+
"""
79+
if not settings.USING_AWS:
80+
if not settings.DEBUG:
81+
raise ValueError(
82+
"Not returning global credentials, AWS STS should always be used in production."
83+
)
84+
return AWSTemporaryCredentials(
85+
access_key_id=settings.AWS_ACCESS_KEY_ID,
86+
secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
87+
# A session token is not needed for the default credentials.
88+
session_token=None,
89+
)
90+
91+
# Limit to 64 characters, as per AWS limitations.
92+
session_name = session_name[:64]
93+
try:
94+
sts_client = get_sts_client()
95+
response = sts_client.assume_role(
96+
RoleArn=settings.AWS_STS_ASSUME_ROLE_ARN,
97+
RoleSessionName=session_name,
98+
Policy=json.dumps(policy),
99+
DurationSeconds=duration,
100+
)
101+
except Exception:
102+
log.exception(
103+
"Error while assuming role to generate temporary credentials",
104+
session_name=session_name,
105+
policy=policy,
106+
duration=duration,
107+
)
108+
raise AWSTemporaryCredentialsError
109+
110+
credentials = response["Credentials"]
111+
return AWSTemporaryCredentials(
112+
access_key_id=credentials["AccessKeyId"],
113+
secret_access_key=credentials["SecretAccessKey"],
114+
session_token=credentials["SessionToken"],
115+
)
116+
117+
118+
def get_s3_build_media_scoped_credentials(
119+
*,
120+
build,
121+
duration=60 * 15,
122+
) -> AWSS3TemporaryCredentials:
123+
"""
124+
Get temporary credentials with read/write access to the build media bucket.
125+
126+
The credentials are scoped to the paths that the build needs to access.
127+
128+
:duration: The duration of the credentials in seconds. Default is 15 minutes.
129+
Note that the minimum duration time is 15 minutes and the maximum is given by the role (defaults to 1 hour).
130+
"""
131+
project = build.project
132+
version = build.version
133+
bucket_arn = f"arn:aws:s3:::{settings.S3_MEDIA_STORAGE_BUCKET}"
134+
storage_paths = version.get_storage_paths()
135+
# Generate the list of allowed prefix resources
136+
# The resulting prefix looks like:
137+
# - html/project/latest/*
138+
# - pdf/project/latest/*
139+
allowed_prefixes = [f"{storage_path}/*" for storage_path in storage_paths]
140+
141+
# Generate the list of allowed object resources in ARN format.
142+
# The resulting ARN looks like:
143+
# arn:aws:s3:::readthedocs-media/html/project/latest/*
144+
# arn:aws:s3:::readthedocs-media/pdf/project/latest/*
145+
allowed_objects_arn = [f"{bucket_arn}/{prefix}" for prefix in allowed_prefixes]
146+
147+
# Inline policy document to limit the permissions of the temporary credentials.
148+
policy = {
149+
"Version": "2012-10-17",
150+
"Statement": [
151+
{
152+
"Effect": "Allow",
153+
"Action": [
154+
"s3:GetObject",
155+
"s3:PutObject",
156+
"s3:DeleteObject",
157+
],
158+
"Resource": allowed_objects_arn,
159+
},
160+
# In order to list the objects in a path, we need to allow the ListBucket action.
161+
# But since that action is not scoped to a path, we need to limit it using a condition.
162+
{
163+
"Effect": "Allow",
164+
"Action": ["s3:ListBucket"],
165+
"Resource": [
166+
bucket_arn,
167+
],
168+
"Condition": {
169+
"StringLike": {
170+
"s3:prefix": allowed_prefixes,
171+
}
172+
},
173+
},
174+
],
175+
}
176+
177+
session_name = f"rtd-{build.id}-{project.slug}-{version.slug}"
178+
credentials = _get_scoped_credentials(
179+
session_name=session_name,
180+
policy=policy,
181+
duration=duration,
182+
)
183+
return AWSS3TemporaryCredentials(
184+
access_key_id=credentials.access_key_id,
185+
secret_access_key=credentials.secret_access_key,
186+
session_token=credentials.session_token,
187+
region_name=settings.AWS_S3_REGION_NAME,
188+
bucket_name=settings.S3_MEDIA_STORAGE_BUCKET,
189+
)
190+
191+
192+
def get_s3_build_tools_scoped_credentials(
193+
*,
194+
build,
195+
duration=60 * 15,
196+
) -> AWSS3TemporaryCredentials:
197+
"""
198+
Get temporary credentials with read-only access to the build-tools bucket.
199+
200+
:param build: The build to get the credentials for.
201+
:param duration: The duration of the credentials in seconds. Default is 15 minutes.
202+
Note that the minimum duration time is 15 minutes and the maximum is given by the role (defaults to 1 hour).
203+
"""
204+
project = build.project
205+
version = build.version
206+
bucket = settings.S3_BUILD_TOOLS_STORAGE_BUCKET
207+
bucket_arn = f"arn:aws:s3:::{bucket}"
208+
209+
# Inline policy to limit the permissions of the temporary credentials.
210+
# The build-tools bucket is publicly readable, so we don't need to limit the permissions to a specific path.
211+
policy = {
212+
"Version": "2012-10-17",
213+
"Statement": [
214+
{
215+
"Effect": "Allow",
216+
"Action": [
217+
"s3:GetObject",
218+
"s3:ListBucket",
219+
],
220+
"Resource": [
221+
bucket_arn,
222+
f"{bucket_arn}/*",
223+
],
224+
},
225+
],
226+
}
227+
session_name = f"rtd-{build.id}-{project.slug}-{version.slug}"
228+
credentials = _get_scoped_credentials(
229+
session_name=session_name,
230+
policy=policy,
231+
duration=duration,
232+
)
233+
return AWSS3TemporaryCredentials(
234+
access_key_id=credentials.access_key_id,
235+
secret_access_key=credentials.secret_access_key,
236+
session_token=credentials.session_token,
237+
region_name=settings.AWS_S3_REGION_NAME,
238+
bucket_name=bucket,
239+
)

readthedocs/aws/tests/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)