Skip to content

Commit

Permalink
--public option for creating public buckets, closes #42
Browse files Browse the repository at this point in the history
Will help with buckets as websites in #21

Includes integration test cowerage for put-object content-type in #43
  • Loading branch information
simonw committed Dec 7, 2021
1 parent a2a642d commit 6e0a2c2
Show file tree
Hide file tree
Showing 4 changed files with 106 additions and 0 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ The `create` command has a number of options:
- `--duration 15m`: For temporary credentials, how long should they last? This can be specified in seconds, minutes or hours using a suffix of `s`, `m` or `h` - but must be between 15 minutes and 12 hours.
- `--username TEXT`: The username to use for the user that is created by the command (or the username of an existing user if you do not want to create a new one). If ommitted a default such as `s3.read-write.static.niche-museums.com` will be used.
- `-c, --create-bucket`: Create the buckets if they do not exist. Without this any missing buckets will be treated as an error.
- `--public`: When creating a bucket, set it so that any file uploaded to that bucket can be downloaded by anyone who knows its filename. This attaches the [public bucket policy](#public-bucket-policy) shown below.
- `--read-only`: The user should only be allowed to read files from the bucket.
- `--write-only`: The user should only be allowed to write files to the bucket, but not read them. This can be useful for logging and backups.
- `--policy filepath-or-string`: A custom policy document (as a file path, literal JSON string or `-` for standard input) - see below.
Expand Down
18 changes: 18 additions & 0 deletions s3_credentials/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,11 @@ def policy(buckets, read_only, write_only, public_bucket):
help="Create buckets if they do not already exist",
is_flag=True,
)
@click.option(
"--public",
help="Make the created bucket public: anyone will be able to download files if they know their name",
is_flag=True,
)
@click.option("--read-only", help="Only allow reading from the bucket", is_flag=True)
@click.option("--write-only", help="Only allow writing to the bucket", is_flag=True)
@click.option(
Expand All @@ -211,6 +216,7 @@ def create(
duration,
username,
create_bucket,
public,
read_only,
write_only,
policy,
Expand Down Expand Up @@ -263,6 +269,10 @@ def log(message):
"LocationConstraint": bucket_region
}
}
bucket_policy = {}
if public:
bucket_policy = policies.bucket_policy_allow_all_get(bucket)

if dry_run:
click.echo(
"Would create bucket: '{}'{}".format(
Expand All @@ -274,12 +284,20 @@ def log(message):
),
)
)
if bucket_policy:
click.echo("... then the following bucket policy:")
click.echo(json.dumps(bucket_policy, indent=4))
else:
s3.create_bucket(Bucket=bucket, **kwargs)
info = "Created bucket: {}".format(bucket)
if bucket_region:
info += "in region: {}".format(bucket_region)
log(info)
if bucket_policy:
s3.put_bucket_policy(
Bucket=bucket, Policy=json.dumps(bucket_policy)
)
log("Attached bucket policy allowing public access")
# At this point the buckets definitely exist - create the inline policy
bucket_access_policy = {}
if policy:
Expand Down
43 changes: 43 additions & 0 deletions tests/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import pytest
import secrets
import time
import urllib

# Mark all tests in this module with "integration":
pytestmark = pytest.mark.integration
Expand Down Expand Up @@ -131,3 +132,45 @@ def cleanup_any_resources():
boto3.resource("s3").Bucket(bucket).objects.all().delete()
# Delete the bucket
s3.delete_bucket(Bucket=bucket)


def test_public_bucket():
bucket_name = "s3-credentials-tests.public-bucket.{}".format(secrets.token_hex(4))
s3 = boto3.client("s3")
assert not bucket_exists(s3, bucket_name)
credentials_decoded = json.loads(
get_output("create", bucket_name, "-c", "--duration", "15m", "--public")
)
assert set(credentials_decoded.keys()) == {
"AccessKeyId",
"SecretAccessKey",
"SessionToken",
"Expiration",
}
# Wait for everything to exist
time.sleep(5)
# Use those credentials to upload a file
content = "<h1>Hello world</h1>"
get_output(
"put-object",
bucket_name,
"hello.html",
"-",
"--content-type",
"text/html",
"--access-key",
credentials_decoded["AccessKeyId"],
"--secret-key",
credentials_decoded["SecretAccessKey"],
"--session-token",
credentials_decoded["SessionToken"],
input=content,
)
# It should be publicly accessible
url = "https://s3.amazonaws.com/{}/hello.html".format(bucket_name)
print(url)
response = urllib.request.urlopen(url)
actual_content = response.read().decode("utf-8")
assert response.status == 200
assert response.headers["content-type"] == "text/html"
assert actual_content == content
44 changes: 44 additions & 0 deletions tests/test_s3_credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,50 @@ def test_create_duration(
]


def test_create_public(mocker):
boto3 = mocker.patch("boto3.client")
boto3.return_value = Mock()
boto3.return_value.create_access_key.return_value = {
"AccessKey": {"AccessKeyId": "access", "SecretAccessKey": "secret"}
}
# Fake that the bucket does not exist
boto3.return_value.head_bucket.side_effect = botocore.exceptions.ClientError(
error_response={}, operation_name=""
)
runner = CliRunner()
with runner.isolated_filesystem():
args = ["create", "pytest-bucket-simonw-1", "-c", "--public"]
result = runner.invoke(cli, args, catch_exceptions=False)
assert result.exit_code == 0
assert result.output == (
"Created bucket: pytest-bucket-simonw-1\n"
"Attached bucket policy allowing public access\n"
"Attached policy s3.read-write.pytest-bucket-simonw-1 to user s3.read-write.pytest-bucket-simonw-1\n"
"Created access key for user: s3.read-write.pytest-bucket-simonw-1\n"
"{\n"
' "AccessKeyId": "access",\n'
' "SecretAccessKey": "secret"\n'
"}\n"
)
assert [str(c) for c in boto3.mock_calls] == [
"call('s3')",
"call('iam')",
"call('sts')",
"call().head_bucket(Bucket='pytest-bucket-simonw-1')",
"call().create_bucket(Bucket='pytest-bucket-simonw-1')",
"call().put_bucket_policy(Bucket='pytest-bucket-simonw-1', "
'Policy=\'{"Version": "2012-10-17", "Statement": [{"Sid": '
'"AllowAllGetObject", "Effect": "Allow", "Principal": "*", "Action": '
'["s3:GetObject"], "Resource": '
'["arn:aws:s3:::pytest-bucket-simonw-1/*"]}]}\')',
"call().get_user(UserName='s3.read-write.pytest-bucket-simonw-1')",
"call().put_user_policy(PolicyDocument='{}', PolicyName='s3.read-write.pytest-bucket-simonw-1', UserName='s3.read-write.pytest-bucket-simonw-1')".format(
READ_WRITE_POLICY.replace("$!BUCKET_NAME!$", "pytest-bucket-simonw-1"),
),
"call().create_access_key(UserName='s3.read-write.pytest-bucket-simonw-1')",
]


def test_create_format_ini(mocker):
boto3 = mocker.patch("boto3.client")
boto3.return_value = Mock()
Expand Down

0 comments on commit 6e0a2c2

Please sign in to comment.