|
| 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 | + ) |
0 commit comments