Skip to content

Commit 54f810a

Browse files
committed
--statement option for create and policy commands, refs #72
1 parent 3eca3a0 commit 54f810a

File tree

6 files changed

+123
-20
lines changed

6 files changed

+123
-20
lines changed

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ The `create` command has a number of options:
9393
- `--read-only`: The user should only be allowed to read files from the bucket.
9494
- `--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.
9595
- `--policy filepath-or-string`: A custom policy document (as a file path, literal JSON string or `-` for standard input) - see below.
96+
- `--statement json-statement`: Custom JSON statement block to be added to the generated policy.
9697
- `--bucket-region`: If creating buckets, the region in which they should be created.
9798
- `--silent`: Don't output details of what is happening, just output the JSON for the created access credentials at the end.
9899
- `--dry-run`: Output details of AWS changes that would have been made without applying them.
@@ -164,6 +165,14 @@ You can also pass `-` to read from standard input, or you can pass the literal J
164165
]
165166
}'
166167
```
168+
You can also specify one or more extra statement blocks that should be added to the generated policy, using `--statement JSON`. This example enables the AWS `textract:` APIs for the generated credentials, useful for using with the [s3-ocr](https://datasette.io/tools/s3-ocr) tool:
169+
```
170+
% s3-credentials create my-s3-bucket --statement '{
171+
"Effect": "Allow",
172+
"Action": "textract:*",
173+
"Resource": "*"
174+
}'
175+
```
167176

168177
## Other commands
169178

@@ -174,6 +183,7 @@ You can use the `s3-credentials policy` command to generate the JSON policy docu
174183
- `--read-only` - generate a read-only policy
175184
- `--write-only` - generate a write-only policy
176185
- `--prefix` - policy should be restricted to keys in the bucket that start with this prefix
186+
- `--statement json-statement`: Custom JSON statement block
177187
- `--public-bucket` - generate a bucket policy for a public bucket
178188

179189
With none of these options it defaults to a read-write policy.

help.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ Options:
8080
--policy POLICY Path to a policy.json file, or literal JSON
8181
string - $!BUCKET_NAME!$ will be replaced with
8282
the name of the bucket
83+
--statement STATEMENT JSON statement to add to the policy
8384
--bucket-region TEXT Region in which to create buckets
8485
--silent Don't show performed steps
8586
--dry-run Show steps without executing them
@@ -301,11 +302,12 @@ Usage: s3-credentials policy [OPTIONS] BUCKETS...
301302
s3-credentials policy my-bucket --read-only
302303
303304
Options:
304-
--read-only Only allow reading from the bucket
305-
--write-only Only allow writing to the bucket
306-
--prefix TEXT Restrict to keys starting with this prefix
307-
--public-bucket Bucket policy for allowing public access
308-
--help Show this message and exit.
305+
--read-only Only allow reading from the bucket
306+
--write-only Only allow writing to the bucket
307+
--prefix TEXT Restrict to keys starting with this prefix
308+
--statement STATEMENT JSON statement to add to the policy
309+
--public-bucket Bucket policy for allowing public access
310+
--help Show this message and exit.
309311
```
310312
### s3-credentials put-object --help
311313

s3_credentials/cli.py

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,27 @@ def convert(self, value, param, ctx):
130130
return integer
131131

132132

133+
class StatementParam(click.ParamType):
134+
"Ensures statement is valid JSON with required fields"
135+
name = "statement"
136+
137+
def convert(self, statement, param, ctx):
138+
try:
139+
data = json.loads(statement)
140+
except ValueError:
141+
self.fail("Invalid JSON string")
142+
if not isinstance(data, dict):
143+
self.fail("JSON must be an object")
144+
missing_keys = {"Effect", "Action", "Resource"} - data.keys()
145+
if missing_keys:
146+
self.fail(
147+
"Statement JSON missing required keys: {}".format(
148+
", ".join(sorted(missing_keys))
149+
)
150+
)
151+
return data
152+
153+
133154
@cli.command()
134155
@click.argument(
135156
"buckets",
@@ -141,12 +162,19 @@ def convert(self, value, param, ctx):
141162
@click.option(
142163
"--prefix", help="Restrict to keys starting with this prefix", default="*"
143164
)
165+
@click.option(
166+
"extra_statements",
167+
"--statement",
168+
multiple=True,
169+
type=StatementParam(),
170+
help="JSON statement to add to the policy",
171+
)
144172
@click.option(
145173
"--public-bucket",
146174
help="Bucket policy for allowing public access",
147175
is_flag=True,
148176
)
149-
def policy(buckets, read_only, write_only, prefix, public_bucket):
177+
def policy(buckets, read_only, write_only, prefix, extra_statements, public_bucket):
150178
"""
151179
Output generated JSON policy for one or more buckets
152180
@@ -183,6 +211,8 @@ def policy(buckets, read_only, write_only, prefix, public_bucket):
183211
statements.extend(policies.write_only_statements(bucket, prefix))
184212
else:
185213
assert False, "Unknown permission: {}".format(permission)
214+
if extra_statements:
215+
statements.extend(extra_statements)
186216
bucket_access_policy = policies.wrap_policy(statements)
187217
click.echo(json.dumps(bucket_access_policy, indent=4))
188218

@@ -229,6 +259,13 @@ def policy(buckets, read_only, write_only, prefix, public_bucket):
229259
type=PolicyParam(),
230260
help="Path to a policy.json file, or literal JSON string - $!BUCKET_NAME!$ will be replaced with the name of the bucket",
231261
)
262+
@click.option(
263+
"extra_statements",
264+
"--statement",
265+
multiple=True,
266+
type=StatementParam(),
267+
help="JSON statement to add to the policy",
268+
)
232269
@click.option("--bucket-region", help="Region in which to create buckets")
233270
@click.option("--silent", help="Don't show performed steps", is_flag=True)
234271
@click.option("--dry-run", help="Show steps without executing them", is_flag=True)
@@ -252,6 +289,7 @@ def create(
252289
read_only,
253290
write_only,
254291
policy,
292+
extra_statements,
255293
bucket_region,
256294
user_permissions_boundary,
257295
silent,
@@ -278,6 +316,7 @@ def create(
278316
raise click.ClickException(
279317
"Cannot use --read-only and --write-only at the same time"
280318
)
319+
extra_statements = list(extra_statements)
281320

282321
def log(message):
283322
if not silent:
@@ -348,7 +387,6 @@ def log(message):
348387
log("Attached bucket policy allowing public access")
349388
# At this point the buckets definitely exist - create the inline policy for assume_role()
350389
assume_role_policy = {}
351-
bucket_access_policy = {}
352390
if policy:
353391
assume_role_policy = json.loads(policy.replace("$!BUCKET_NAME!$", bucket))
354392
else:
@@ -364,6 +402,7 @@ def log(message):
364402
statements.extend(policies.write_only_statements(bucket, prefix))
365403
else:
366404
assert False, "Unknown permission: {}".format(permission)
405+
statements.extend(extra_statements)
367406
assume_role_policy = policies.wrap_policy(statements)
368407

369408
if duration:
@@ -382,7 +421,7 @@ def log(message):
382421
credentials_response = sts.assume_role(
383422
RoleArn=s3_role_arn,
384423
RoleSessionName="s3.{permission}.{buckets}".format(
385-
permission="custom" if policy else permission,
424+
permission="custom" if (policy or extra_statements) else permission,
386425
buckets=",".join(buckets),
387426
),
388427
Policy=json.dumps(assume_role_policy),
@@ -410,7 +449,8 @@ def log(message):
410449
if not username:
411450
# Default username is "s3.read-write.bucket1,bucket2"
412451
username = "s3.{permission}.{buckets}".format(
413-
permission="custom" if policy else permission, buckets=",".join(buckets)
452+
permission="custom" if (policy or extra_statements) else permission,
453+
buckets=",".join(buckets),
414454
)
415455
if dry_run or (not user_exists(iam, username)):
416456
kwargs = {"UserName": username}
@@ -443,18 +483,18 @@ def log(message):
443483
user_policy = {}
444484
for bucket in buckets:
445485
policy_name = "s3.{permission}.{bucket}".format(
446-
permission="custom" if policy else permission,
486+
permission="custom" if (policy or extra_statements) else permission,
447487
bucket=bucket,
448488
)
449489
if policy:
450490
user_policy = json.loads(policy.replace("$!BUCKET_NAME!$", bucket))
451491
else:
452492
if permission == "read-write":
453-
user_policy = policies.read_write(bucket, prefix)
493+
user_policy = policies.read_write(bucket, prefix, extra_statements)
454494
elif permission == "read-only":
455-
user_policy = policies.read_only(bucket, prefix)
495+
user_policy = policies.read_only(bucket, prefix, extra_statements)
456496
elif permission == "write-only":
457-
user_policy = policies.write_only(bucket, prefix)
497+
user_policy = policies.write_only(bucket, prefix, extra_statements)
458498
else:
459499
assert False, "Unknown permission: {}".format(permission)
460500

s3_credentials/policies.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1-
def read_write(bucket, prefix="*"):
2-
return wrap_policy(read_write_statements(bucket, prefix=prefix))
1+
def read_write(bucket, prefix="*", extra_statements=None):
2+
statements = read_write_statements(bucket, prefix=prefix)
3+
if extra_statements:
4+
statements.extend(extra_statements)
5+
return wrap_policy(statements)
36

47

58
def read_write_statements(bucket, prefix="*"):
@@ -15,8 +18,11 @@ def read_write_statements(bucket, prefix="*"):
1518
]
1619

1720

18-
def read_only(bucket, prefix="*"):
19-
return wrap_policy(read_only_statements(bucket, prefix))
21+
def read_only(bucket, prefix="*", extra_statements=None):
22+
statements = read_only_statements(bucket, prefix=prefix)
23+
if extra_statements:
24+
statements.extend(extra_statements)
25+
return wrap_policy(statements)
2026

2127

2228
def read_only_statements(bucket, prefix="*"):
@@ -70,8 +76,11 @@ def read_only_statements(bucket, prefix="*"):
7076
]
7177

7278

73-
def write_only(bucket, prefix="*"):
74-
return wrap_policy(write_only_statements(bucket, prefix))
79+
def write_only(bucket, prefix="*", extra_statements=None):
80+
statements = write_only_statements(bucket, prefix=prefix)
81+
if extra_statements:
82+
statements.extend(extra_statements)
83+
return wrap_policy(statements)
7584

7685

7786
def write_only_statements(bucket, prefix="*"):

tests/test_dry_run.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77

88
def assert_match_with_wildcards(pattern, input):
9-
# Pattern language is simple: '*' becomes '*?'
9+
# Pattern language is simple: '*' becomes '.*?'
1010
bits = pattern.split("*")
1111
regex = "^{}$".format(".*?".join(re.escape(bit) for bit in bits))
1212
print(regex)
@@ -61,6 +61,17 @@ def assert_match_with_wildcards(pattern, input):
6161
Would call create access key for user 's3.read-write.my-bucket'"""
6262
),
6363
),
64+
(
65+
[
66+
"--statement",
67+
'{"Effect": "Allow", "Action": "textract:*", "Resource": "*"}',
68+
],
69+
(
70+
"""Would create bucket: 'my-bucket'
71+
Would create user: 's3.custom.my-bucket' with permissions boundary: 'arn:aws:iam::aws:policy/AmazonS3FullAccess'
72+
*"Action": "textract:*"""
73+
),
74+
),
6475
),
6576
)
6677
def test_dry_run(options, expected):

tests/test_s3_credentials.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,16 +282,24 @@ def test_list_buckets_details(stub_s3):
282282
READ_ONLY_POLICY = '{"Version": "2012-10-17", "Statement": [{"Effect": "Allow", "Action": ["s3:ListBucket", "s3:GetBucketLocation"], "Resource": ["arn:aws:s3:::pytest-bucket-simonw-1"]}, {"Effect": "Allow", "Action": ["s3:GetObject", "s3:GetObjectAcl", "s3:GetObjectLegalHold", "s3:GetObjectRetention", "s3:GetObjectTagging"], "Resource": ["arn:aws:s3:::pytest-bucket-simonw-1/*"]}]}'
283283
WRITE_ONLY_POLICY = '{"Version": "2012-10-17", "Statement": [{"Effect": "Allow", "Action": ["s3:PutObject"], "Resource": ["arn:aws:s3:::pytest-bucket-simonw-1/*"]}]}'
284284
PREFIX_POLICY = '{"Version": "2012-10-17", "Statement": [{"Effect": "Allow", "Action": ["s3:GetBucketLocation"], "Resource": ["arn:aws:s3:::pytest-bucket-simonw-1"]}, {"Effect": "Allow", "Action": ["s3:ListBucket"], "Resource": ["arn:aws:s3:::pytest-bucket-simonw-1"], "Condition": {"StringLike": {"s3:prefix": ["my-prefix/*"]}}}, {"Effect": "Allow", "Action": ["s3:GetObject", "s3:GetObjectAcl", "s3:GetObjectLegalHold", "s3:GetObjectRetention", "s3:GetObjectTagging"], "Resource": ["arn:aws:s3:::pytest-bucket-simonw-1/my-prefix/*"]}, {"Effect": "Allow", "Action": ["s3:PutObject", "s3:DeleteObject"], "Resource": ["arn:aws:s3:::pytest-bucket-simonw-1/my-prefix/*"]}]}'
285+
EXTRA_STATEMENTS_POLICY = '{"Version": "2012-10-17", "Statement": [{"Effect": "Allow", "Action": ["s3:ListBucket", "s3:GetBucketLocation"], "Resource": ["arn:aws:s3:::pytest-bucket-simonw-1"]}, {"Effect": "Allow", "Action": ["s3:GetObject", "s3:GetObjectAcl", "s3:GetObjectLegalHold", "s3:GetObjectRetention", "s3:GetObjectTagging"], "Resource": ["arn:aws:s3:::pytest-bucket-simonw-1/*"]}, {"Effect": "Allow", "Action": ["s3:PutObject", "s3:DeleteObject"], "Resource": ["arn:aws:s3:::pytest-bucket-simonw-1/*"]}, {"Effect": "Allow", "Action": "textract:*", "Resource": "*"}]}'
285286

286287
# Used by both test_create and test_create_duration
287288
CREATE_TESTS = (
289+
# options,use_policy_stdin,expected_policy,expected_name_fragment
288290
([], False, READ_WRITE_POLICY, "read-write"),
289291
(["--read-only"], False, READ_ONLY_POLICY, "read-only"),
290292
(["--write-only"], False, WRITE_ONLY_POLICY, "write-only"),
291293
(["--prefix", "my-prefix/"], False, PREFIX_POLICY, "read-write"),
292294
(["--policy", "POLICYFILEPATH"], False, CUSTOM_POLICY, "custom"),
293295
(["--policy", "-"], True, CUSTOM_POLICY, "custom"),
294296
(["--policy", CUSTOM_POLICY], False, CUSTOM_POLICY, "custom"),
297+
(
298+
["--statement", '{"Effect": "Allow", "Action": "textract:*", "Resource": "*"}'],
299+
False,
300+
EXTRA_STATEMENTS_POLICY,
301+
"custom",
302+
),
295303
)
296304

297305

@@ -344,6 +352,22 @@ def test_create(
344352
]
345353

346354

355+
@pytest.mark.parametrize(
356+
"statement,expected_error",
357+
(
358+
("", "Invalid JSON string"),
359+
("{}", "missing required keys: Action, Effect, Resource"),
360+
('{"Action": 1}', "missing required keys: Effect, Resource"),
361+
('{"Action": 1, "Effect": 2}', "missing required keys: Resource"),
362+
),
363+
)
364+
def test_create_statement_error(statement, expected_error):
365+
runner = CliRunner()
366+
result = runner.invoke(cli, ["create", "--statement", statement])
367+
assert result.exit_code == 2
368+
assert expected_error in result.output
369+
370+
347371
@pytest.fixture
348372
def mocked_for_duration(mocker):
349373
boto3 = mocker.patch("boto3.client")
@@ -820,6 +844,13 @@ def test_auth_option_errors(extra_option):
820844
(["--read-only"], READ_ONLY_POLICY),
821845
(["--write-only"], WRITE_ONLY_POLICY),
822846
(["--prefix", "my-prefix/"], PREFIX_POLICY),
847+
(
848+
[
849+
"--statement",
850+
'{"Effect": "Allow", "Action": "textract:*", "Resource": "*"}',
851+
],
852+
EXTRA_STATEMENTS_POLICY,
853+
),
823854
),
824855
)
825856
def test_policy(options, expected):

0 commit comments

Comments
 (0)