cli.py 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146
  1. from re import A
  2. import boto3
  3. import botocore
  4. import click
  5. import configparser
  6. from csv import DictWriter
  7. import io
  8. import itertools
  9. import json
  10. import mimetypes
  11. import os
  12. import re
  13. import sys
  14. import textwrap
  15. from . import policies
  16. def bucket_exists(s3, bucket):
  17. try:
  18. s3.head_bucket(Bucket=bucket)
  19. return True
  20. except botocore.exceptions.ClientError:
  21. return False
  22. def user_exists(iam, username):
  23. try:
  24. iam.get_user(UserName=username)
  25. return True
  26. except iam.exceptions.NoSuchEntityException:
  27. return False
  28. def common_boto3_options(fn):
  29. for decorator in reversed(
  30. (
  31. click.option(
  32. "--access-key",
  33. help="AWS access key ID",
  34. ),
  35. click.option(
  36. "--secret-key",
  37. help="AWS secret access key",
  38. ),
  39. click.option(
  40. "--session-token",
  41. help="AWS session token",
  42. ),
  43. click.option(
  44. "--endpoint-url",
  45. help="Custom endpoint URL",
  46. ),
  47. click.option(
  48. "-a",
  49. "--auth",
  50. type=click.File("r"),
  51. help="Path to JSON/INI file containing credentials",
  52. ),
  53. )
  54. ):
  55. fn = decorator(fn)
  56. return fn
  57. def common_output_options(fn):
  58. for decorator in reversed(
  59. (
  60. click.option("--nl", help="Output newline-delimited JSON", is_flag=True),
  61. click.option("--csv", help="Output CSV", is_flag=True),
  62. click.option("--tsv", help="Output TSV", is_flag=True),
  63. )
  64. ):
  65. fn = decorator(fn)
  66. return fn
  67. @click.group()
  68. @click.version_option()
  69. def cli():
  70. "A tool for creating credentials for accessing S3 buckets"
  71. class PolicyParam(click.ParamType):
  72. "Returns string of guaranteed well-formed JSON"
  73. name = "policy"
  74. def convert(self, policy, param, ctx):
  75. if policy.strip().startswith("{"):
  76. # Verify policy string is valid JSON
  77. try:
  78. json.loads(policy)
  79. except ValueError:
  80. self.fail("Invalid JSON string")
  81. return policy
  82. else:
  83. # Assume policy is a file path or '-'
  84. try:
  85. with click.open_file(policy) as f:
  86. contents = f.read()
  87. try:
  88. json.loads(contents)
  89. return contents
  90. except ValueError:
  91. self.fail(
  92. "{} contained invalid JSON".format(
  93. "Input" if policy == "-" else "File"
  94. )
  95. )
  96. except FileNotFoundError:
  97. self.fail("File not found")
  98. class DurationParam(click.ParamType):
  99. name = "duration"
  100. pattern = re.compile(r"^(\d+)(m|h|s)?$")
  101. def convert(self, value, param, ctx):
  102. match = self.pattern.match(value)
  103. if match is None:
  104. self.fail("Duration must be of form 3600s or 15m or 2h")
  105. integer_string, suffix = match.groups()
  106. integer = int(integer_string)
  107. if suffix == "m":
  108. integer *= 60
  109. elif suffix == "h":
  110. integer *= 3600
  111. # Must be between 15 minutes and 12 hours
  112. if not (15 * 60 <= integer <= 12 * 60 * 60):
  113. self.fail("Duration must be between 15 minutes and 12 hours")
  114. return integer
  115. class StatementParam(click.ParamType):
  116. "Ensures statement is valid JSON with required fields"
  117. name = "statement"
  118. def convert(self, statement, param, ctx):
  119. try:
  120. data = json.loads(statement)
  121. except ValueError:
  122. self.fail("Invalid JSON string")
  123. if not isinstance(data, dict):
  124. self.fail("JSON must be an object")
  125. missing_keys = {"Effect", "Action", "Resource"} - data.keys()
  126. if missing_keys:
  127. self.fail(
  128. "Statement JSON missing required keys: {}".format(
  129. ", ".join(sorted(missing_keys))
  130. )
  131. )
  132. return data
  133. @cli.command()
  134. @click.argument(
  135. "buckets",
  136. nargs=-1,
  137. required=True,
  138. )
  139. @click.option("--read-only", help="Only allow reading from the bucket", is_flag=True)
  140. @click.option("--write-only", help="Only allow writing to the bucket", is_flag=True)
  141. @click.option(
  142. "--prefix", help="Restrict to keys starting with this prefix", default="*"
  143. )
  144. @click.option(
  145. "extra_statements",
  146. "--statement",
  147. multiple=True,
  148. type=StatementParam(),
  149. help="JSON statement to add to the policy",
  150. )
  151. @click.option(
  152. "--public-bucket",
  153. help="Bucket policy for allowing public access",
  154. is_flag=True,
  155. )
  156. def policy(buckets, read_only, write_only, prefix, extra_statements, public_bucket):
  157. """
  158. Output generated JSON policy for one or more buckets
  159. Takes the same options as s3-credentials create
  160. To output a read-only JSON policy for a bucket:
  161. s3-credentials policy my-bucket --read-only
  162. """
  163. "Generate JSON policy for one or more buckets"
  164. if public_bucket:
  165. if len(buckets) != 1:
  166. raise click.ClickException(
  167. "--public-bucket policy can only be generated for a single bucket"
  168. )
  169. click.echo(
  170. json.dumps(policies.bucket_policy_allow_all_get(buckets[0]), indent=4)
  171. )
  172. return
  173. permission = "read-write"
  174. if read_only:
  175. permission = "read-only"
  176. if write_only:
  177. permission = "write-only"
  178. statements = []
  179. if permission == "read-write":
  180. for bucket in buckets:
  181. statements.extend(policies.read_write_statements(bucket, prefix))
  182. elif permission == "read-only":
  183. for bucket in buckets:
  184. statements.extend(policies.read_only_statements(bucket, prefix))
  185. elif permission == "write-only":
  186. for bucket in buckets:
  187. statements.extend(policies.write_only_statements(bucket, prefix))
  188. else:
  189. assert False, "Unknown permission: {}".format(permission)
  190. if extra_statements:
  191. statements.extend(extra_statements)
  192. bucket_access_policy = policies.wrap_policy(statements)
  193. click.echo(json.dumps(bucket_access_policy, indent=4))
  194. @cli.command()
  195. @click.argument(
  196. "buckets",
  197. nargs=-1,
  198. required=True,
  199. )
  200. @click.option(
  201. "format_",
  202. "-f",
  203. "--format",
  204. type=click.Choice(["ini", "json"]),
  205. default="json",
  206. help="Output format for credentials",
  207. )
  208. @click.option(
  209. "-d",
  210. "--duration",
  211. type=DurationParam(),
  212. help="How long should these credentials work for? Default is forever, use 3600 for 3600 seconds, 15m for 15 minutes, 1h for 1 hour",
  213. )
  214. @click.option("--username", help="Username to create or existing user to use")
  215. @click.option(
  216. "-c",
  217. "--create-bucket",
  218. help="Create buckets if they do not already exist",
  219. is_flag=True,
  220. )
  221. @click.option(
  222. "--prefix", help="Restrict to keys starting with this prefix", default="*"
  223. )
  224. @click.option(
  225. "--public",
  226. help="Make the created bucket public: anyone will be able to download files if they know their name",
  227. is_flag=True,
  228. )
  229. @click.option("--read-only", help="Only allow reading from the bucket", is_flag=True)
  230. @click.option("--write-only", help="Only allow writing to the bucket", is_flag=True)
  231. @click.option(
  232. "--policy",
  233. type=PolicyParam(),
  234. help="Path to a policy.json file, or literal JSON string - $!BUCKET_NAME!$ will be replaced with the name of the bucket",
  235. )
  236. @click.option(
  237. "extra_statements",
  238. "--statement",
  239. multiple=True,
  240. type=StatementParam(),
  241. help="JSON statement to add to the policy",
  242. )
  243. @click.option("--bucket-region", help="Region in which to create buckets")
  244. @click.option("--silent", help="Don't show performed steps", is_flag=True)
  245. @click.option("--dry-run", help="Show steps without executing them", is_flag=True)
  246. @click.option(
  247. "--user-permissions-boundary",
  248. help=(
  249. "Custom permissions boundary to use for created users, or 'none' to "
  250. "create without. Defaults to limiting to S3 based on "
  251. "--read-only and --write-only options."
  252. ),
  253. )
  254. @common_boto3_options
  255. def create(
  256. buckets,
  257. format_,
  258. duration,
  259. username,
  260. create_bucket,
  261. prefix,
  262. public,
  263. read_only,
  264. write_only,
  265. policy,
  266. extra_statements,
  267. bucket_region,
  268. user_permissions_boundary,
  269. silent,
  270. dry_run,
  271. **boto_options
  272. ):
  273. """
  274. Create and return new AWS credentials for specified S3 buckets - optionally
  275. also creating the bucket if it does not yet exist.
  276. To create a new bucket and output read-write credentials:
  277. s3-credentials create my-new-bucket -c
  278. To create read-only credentials for an existing bucket:
  279. s3-credentials create my-existing-bucket --read-only
  280. To create write-only credentials that are only valid for 15 minutes:
  281. s3-credentials create my-existing-bucket --write-only -d 15m
  282. """
  283. if read_only and write_only:
  284. raise click.ClickException(
  285. "Cannot use --read-only and --write-only at the same time"
  286. )
  287. extra_statements = list(extra_statements)
  288. def log(message):
  289. if not silent:
  290. click.echo(message, err=True)
  291. permission = "read-write"
  292. if read_only:
  293. permission = "read-only"
  294. if write_only:
  295. permission = "write-only"
  296. s3 = None
  297. iam = None
  298. sts = None
  299. if not dry_run:
  300. s3 = make_client("s3", **boto_options)
  301. iam = make_client("iam", **boto_options)
  302. sts = make_client("sts", **boto_options)
  303. # Verify buckets
  304. for bucket in buckets:
  305. # Create bucket if it doesn't exist
  306. if dry_run or (not bucket_exists(s3, bucket)):
  307. if (not dry_run) and (not create_bucket):
  308. raise click.ClickException(
  309. "Bucket does not exist: {} - try --create-bucket to create it".format(
  310. bucket
  311. )
  312. )
  313. if dry_run or create_bucket:
  314. kwargs = {}
  315. if bucket_region:
  316. kwargs = {
  317. "CreateBucketConfiguration": {
  318. "LocationConstraint": bucket_region
  319. }
  320. }
  321. bucket_policy = {}
  322. if public:
  323. bucket_policy = policies.bucket_policy_allow_all_get(bucket)
  324. if dry_run:
  325. click.echo(
  326. "Would create bucket: '{}'{}".format(
  327. bucket,
  328. (
  329. " with args {}".format(json.dumps(kwargs, indent=4))
  330. if kwargs
  331. else ""
  332. ),
  333. )
  334. )
  335. if bucket_policy:
  336. click.echo("... then attach the following bucket policy to it:")
  337. click.echo(json.dumps(bucket_policy, indent=4))
  338. else:
  339. s3.create_bucket(Bucket=bucket, **kwargs)
  340. info = "Created bucket: {}".format(bucket)
  341. if bucket_region:
  342. info += " in region: {}".format(bucket_region)
  343. log(info)
  344. if bucket_policy:
  345. s3.put_bucket_policy(
  346. Bucket=bucket, Policy=json.dumps(bucket_policy)
  347. )
  348. log("Attached bucket policy allowing public access")
  349. # At this point the buckets definitely exist - create the inline policy for assume_role()
  350. assume_role_policy = {}
  351. if policy:
  352. assume_role_policy = json.loads(policy.replace("$!BUCKET_NAME!$", bucket))
  353. else:
  354. statements = []
  355. if permission == "read-write":
  356. for bucket in buckets:
  357. statements.extend(policies.read_write_statements(bucket, prefix))
  358. elif permission == "read-only":
  359. for bucket in buckets:
  360. statements.extend(policies.read_only_statements(bucket, prefix))
  361. elif permission == "write-only":
  362. for bucket in buckets:
  363. statements.extend(policies.write_only_statements(bucket, prefix))
  364. else:
  365. assert False, "Unknown permission: {}".format(permission)
  366. statements.extend(extra_statements)
  367. assume_role_policy = policies.wrap_policy(statements)
  368. if duration:
  369. # We're going to use sts.assume_role() rather than creating a user
  370. if dry_run:
  371. click.echo("Would ensure role: 's3-credentials.AmazonS3FullAccess'")
  372. click.echo(
  373. "Would assume role using following policy for {} seconds:".format(
  374. duration
  375. )
  376. )
  377. click.echo(json.dumps(assume_role_policy, indent=4))
  378. else:
  379. s3_role_arn = ensure_s3_role_exists(iam, sts)
  380. log("Assume role against {} for {}s".format(s3_role_arn, duration))
  381. credentials_response = sts.assume_role(
  382. RoleArn=s3_role_arn,
  383. RoleSessionName="s3.{permission}.{buckets}".format(
  384. permission="custom" if (policy or extra_statements) else permission,
  385. buckets=",".join(buckets),
  386. ),
  387. Policy=json.dumps(assume_role_policy),
  388. DurationSeconds=duration,
  389. )
  390. if format_ == "ini":
  391. click.echo(
  392. (
  393. "[default]\naws_access_key_id={}\n"
  394. "aws_secret_access_key={}\naws_session_token={}"
  395. ).format(
  396. credentials_response["Credentials"]["AccessKeyId"],
  397. credentials_response["Credentials"]["SecretAccessKey"],
  398. credentials_response["Credentials"]["SessionToken"],
  399. )
  400. )
  401. else:
  402. click.echo(
  403. json.dumps(
  404. credentials_response["Credentials"], indent=4, default=str
  405. )
  406. )
  407. return
  408. # No duration, so wo create a new user so we can issue non-expiring credentials
  409. if not username:
  410. # Default username is "s3.read-write.bucket1,bucket2"
  411. username = "s3.{permission}.{buckets}".format(
  412. permission="custom" if (policy or extra_statements) else permission,
  413. buckets=",".join(buckets),
  414. )
  415. if dry_run or (not user_exists(iam, username)):
  416. kwargs = {"UserName": username}
  417. if user_permissions_boundary != "none":
  418. # This is a user-account level limitation, it does not grant
  419. # permissions on its own but is a useful extra level of defense
  420. # https://github.com/simonw/s3-credentials/issues/1#issuecomment-958201717
  421. if not user_permissions_boundary:
  422. # Pick one based on --read-only/--write-only
  423. if read_only:
  424. user_permissions_boundary = (
  425. "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess"
  426. )
  427. else:
  428. # Need full access in order to be able to write
  429. user_permissions_boundary = (
  430. "arn:aws:iam::aws:policy/AmazonS3FullAccess"
  431. )
  432. kwargs["PermissionsBoundary"] = user_permissions_boundary
  433. info = " user: '{}'".format(username)
  434. if user_permissions_boundary != "none":
  435. info += " with permissions boundary: '{}'".format(user_permissions_boundary)
  436. if dry_run:
  437. click.echo("Would create{}".format(info))
  438. else:
  439. iam.create_user(**kwargs)
  440. log("Created {}".format(info))
  441. # Add inline policies to the user so they can access the buckets
  442. user_policy = {}
  443. for bucket in buckets:
  444. policy_name = "s3.{permission}.{bucket}".format(
  445. permission="custom" if (policy or extra_statements) else permission,
  446. bucket=bucket,
  447. )
  448. if policy:
  449. user_policy = json.loads(policy.replace("$!BUCKET_NAME!$", bucket))
  450. else:
  451. if permission == "read-write":
  452. user_policy = policies.read_write(bucket, prefix, extra_statements)
  453. elif permission == "read-only":
  454. user_policy = policies.read_only(bucket, prefix, extra_statements)
  455. elif permission == "write-only":
  456. user_policy = policies.write_only(bucket, prefix, extra_statements)
  457. else:
  458. assert False, "Unknown permission: {}".format(permission)
  459. if dry_run:
  460. click.echo(
  461. "Would attach policy called '{}' to user '{}', details:\n{}".format(
  462. policy_name,
  463. username,
  464. json.dumps(user_policy, indent=4),
  465. )
  466. )
  467. else:
  468. iam.put_user_policy(
  469. PolicyDocument=json.dumps(user_policy),
  470. PolicyName=policy_name,
  471. UserName=username,
  472. )
  473. log("Attached policy {} to user {}".format(policy_name, username))
  474. # Retrieve and print out the credentials
  475. if dry_run:
  476. click.echo("Would call create access key for user '{}'".format(username))
  477. else:
  478. response = iam.create_access_key(
  479. UserName=username,
  480. )
  481. log("Created access key for user: {}".format(username))
  482. if format_ == "ini":
  483. click.echo(
  484. ("[default]\naws_access_key_id={}\n" "aws_secret_access_key={}").format(
  485. response["AccessKey"]["AccessKeyId"],
  486. response["AccessKey"]["SecretAccessKey"],
  487. )
  488. )
  489. elif format_ == "json":
  490. click.echo(json.dumps(response["AccessKey"], indent=4, default=str))
  491. @cli.command()
  492. @common_boto3_options
  493. def whoami(**boto_options):
  494. "Identify currently authenticated user"
  495. sts = make_client("sts", **boto_options)
  496. identity = sts.get_caller_identity()
  497. identity.pop("ResponseMetadata")
  498. click.echo(json.dumps(identity, indent=4, default=str))
  499. @cli.command()
  500. @common_output_options
  501. @common_boto3_options
  502. def list_users(nl, csv, tsv, **boto_options):
  503. """
  504. List all users for this account
  505. s3-credentials list-users
  506. Add --csv or --csv for CSV or TSV format:
  507. s3-credentials list-users --csv
  508. """
  509. iam = make_client("iam", **boto_options)
  510. output(
  511. paginate(iam, "list_users", "Users"),
  512. (
  513. "UserName",
  514. "UserId",
  515. "Arn",
  516. "Path",
  517. "CreateDate",
  518. "PasswordLastUsed",
  519. "PermissionsBoundary",
  520. "Tags",
  521. ),
  522. nl,
  523. csv,
  524. tsv,
  525. )
  526. @cli.command()
  527. @click.argument("role_names", nargs=-1)
  528. @click.option("--details", help="Include attached policies (slower)", is_flag=True)
  529. @common_output_options
  530. @common_boto3_options
  531. def list_roles(role_names, details, nl, csv, tsv, **boto_options):
  532. """
  533. List roles
  534. To list all roles for this AWS account:
  535. s3-credentials list-roles
  536. Add --csv or --csv for CSV or TSV format:
  537. s3-credentials list-roles --csv
  538. For extra details per role (much slower) add --details
  539. s3-credentials list-roles --details
  540. """
  541. iam = make_client("iam", **boto_options)
  542. headers = (
  543. "Path",
  544. "RoleName",
  545. "RoleId",
  546. "Arn",
  547. "CreateDate",
  548. "AssumeRolePolicyDocument",
  549. "Description",
  550. "MaxSessionDuration",
  551. "PermissionsBoundary",
  552. "Tags",
  553. "RoleLastUsed",
  554. )
  555. if details:
  556. headers += ("inline_policies", "attached_policies")
  557. def iterate():
  558. for role in paginate(iam, "list_roles", "Roles"):
  559. if role_names and role["RoleName"] not in role_names:
  560. continue
  561. if details:
  562. role_name = role["RoleName"]
  563. role["inline_policies"] = []
  564. # Get inline policy names, then policy for each one
  565. for policy_name in paginate(
  566. iam, "list_role_policies", "PolicyNames", RoleName=role_name
  567. ):
  568. role_policy_response = iam.get_role_policy(
  569. RoleName=role_name,
  570. PolicyName=policy_name,
  571. )
  572. role_policy_response.pop("ResponseMetadata", None)
  573. role["inline_policies"].append(role_policy_response)
  574. # Get attached managed policies
  575. role["attached_policies"] = []
  576. for attached in paginate(
  577. iam,
  578. "list_attached_role_policies",
  579. "AttachedPolicies",
  580. RoleName=role_name,
  581. ):
  582. policy_arn = attached["PolicyArn"]
  583. attached_policy_response = iam.get_policy(
  584. PolicyArn=policy_arn,
  585. )
  586. policy_details = attached_policy_response["Policy"]
  587. # Also need to fetch the policy JSON
  588. version_id = policy_details["DefaultVersionId"]
  589. policy_version_response = iam.get_policy_version(
  590. PolicyArn=policy_arn,
  591. VersionId=version_id,
  592. )
  593. policy_details["PolicyVersion"] = policy_version_response[
  594. "PolicyVersion"
  595. ]
  596. role["attached_policies"].append(policy_details)
  597. yield role
  598. output(iterate(), headers, nl, csv, tsv)
  599. @cli.command()
  600. @click.argument("usernames", nargs=-1)
  601. @common_boto3_options
  602. def list_user_policies(usernames, **boto_options):
  603. """
  604. List inline policies for specified users
  605. s3-credentials list-user-policies username
  606. Returns policies for all users if no usernames are provided.
  607. """
  608. iam = make_client("iam", **boto_options)
  609. if not usernames:
  610. usernames = [user["UserName"] for user in paginate(iam, "list_users", "Users")]
  611. for username in usernames:
  612. click.echo("User: {}".format(username))
  613. for policy_name in paginate(
  614. iam, "list_user_policies", "PolicyNames", UserName=username
  615. ):
  616. click.echo("PolicyName: {}".format(policy_name))
  617. policy_response = iam.get_user_policy(
  618. UserName=username, PolicyName=policy_name
  619. )
  620. click.echo(
  621. json.dumps(policy_response["PolicyDocument"], indent=4, default=str)
  622. )
  623. @cli.command()
  624. @click.argument("buckets", nargs=-1)
  625. @click.option("--details", help="Include extra bucket details (slower)", is_flag=True)
  626. @common_output_options
  627. @common_boto3_options
  628. def list_buckets(buckets, details, nl, csv, tsv, **boto_options):
  629. """
  630. List buckets
  631. To list all buckets and their creation time as JSON:
  632. s3-credentials list-buckets
  633. Add --csv or --csv for CSV or TSV format:
  634. s3-credentials list-buckets --csv
  635. For extra details per bucket (much slower) add --details
  636. s3-credentials list-buckets --details
  637. """
  638. s3 = make_client("s3", **boto_options)
  639. headers = ["Name", "CreationDate"]
  640. if details:
  641. headers += ["bucket_acl", "public_access_block", "bucket_website"]
  642. def iterator():
  643. for bucket in s3.list_buckets()["Buckets"]:
  644. if buckets and (bucket["Name"] not in buckets):
  645. continue
  646. if details:
  647. bucket_acl = dict(
  648. (key, value)
  649. for key, value in s3.get_bucket_acl(
  650. Bucket=bucket["Name"],
  651. ).items()
  652. if key != "ResponseMetadata"
  653. )
  654. try:
  655. pab = s3.get_public_access_block(
  656. Bucket=bucket["Name"],
  657. )["PublicAccessBlockConfiguration"]
  658. except s3.exceptions.ClientError:
  659. pab = None
  660. try:
  661. bucket_website = dict(
  662. (key, value)
  663. for key, value in s3.get_bucket_website(
  664. Bucket=bucket["Name"],
  665. ).items()
  666. if key != "ResponseMetadata"
  667. )
  668. except s3.exceptions.ClientError:
  669. bucket_website = None
  670. bucket["bucket_acl"] = bucket_acl
  671. bucket["public_access_block"] = pab
  672. bucket["bucket_website"] = bucket_website
  673. yield bucket
  674. output(iterator(), headers, nl, csv, tsv)
  675. @cli.command()
  676. @click.argument("usernames", nargs=-1, required=True)
  677. @common_boto3_options
  678. def delete_user(usernames, **boto_options):
  679. """
  680. Delete specified users, their access keys and their inline policies
  681. s3-credentials delete-user username1 username2
  682. """
  683. iam = make_client("iam", **boto_options)
  684. for username in usernames:
  685. click.echo("User: {}".format(username))
  686. # Fetch and delete their policies
  687. policy_names_to_delete = list(
  688. paginate(iam, "list_user_policies", "PolicyNames", UserName=username)
  689. )
  690. for policy_name in policy_names_to_delete:
  691. iam.delete_user_policy(
  692. UserName=username,
  693. PolicyName=policy_name,
  694. )
  695. click.echo(" Deleted policy: {}".format(policy_name))
  696. # Fetch and delete their access keys
  697. access_key_ids_to_delete = [
  698. access_key["AccessKeyId"]
  699. for access_key in paginate(
  700. iam, "list_access_keys", "AccessKeyMetadata", UserName=username
  701. )
  702. ]
  703. for access_key_id in access_key_ids_to_delete:
  704. iam.delete_access_key(
  705. UserName=username,
  706. AccessKeyId=access_key_id,
  707. )
  708. click.echo(" Deleted access key: {}".format(access_key_id))
  709. iam.delete_user(UserName=username)
  710. click.echo(" Deleted user")
  711. def make_client(service, access_key, secret_key, session_token, endpoint_url, auth):
  712. if auth:
  713. if access_key or secret_key or session_token:
  714. raise click.ClickException(
  715. "--auth cannot be used with --access-key, --secret-key or --session-token"
  716. )
  717. auth_content = auth.read().strip()
  718. if auth_content.startswith("{"):
  719. # Treat as JSON
  720. decoded = json.loads(auth_content)
  721. access_key = decoded.get("AccessKeyId")
  722. secret_key = decoded.get("SecretAccessKey")
  723. session_token = decoded.get("SessionToken")
  724. else:
  725. # Treat as INI
  726. config = configparser.ConfigParser()
  727. config.read_string(auth_content)
  728. # Use the first section that has an aws_access_key_id
  729. for section in config.sections():
  730. if "aws_access_key_id" in config[section]:
  731. access_key = config[section].get("aws_access_key_id")
  732. secret_key = config[section].get("aws_secret_access_key")
  733. session_token = config[section].get("aws_session_token")
  734. break
  735. kwargs = {}
  736. if access_key:
  737. kwargs["aws_access_key_id"] = access_key
  738. if secret_key:
  739. kwargs["aws_secret_access_key"] = secret_key
  740. if session_token:
  741. kwargs["aws_session_token"] = session_token
  742. if endpoint_url:
  743. kwargs["endpoint_url"] = endpoint_url
  744. return boto3.client(service, **kwargs)
  745. def ensure_s3_role_exists(iam, sts):
  746. "Create s3-credentials.AmazonS3FullAccess role if not exists, return ARN"
  747. role_name = "s3-credentials.AmazonS3FullAccess"
  748. account_id = sts.get_caller_identity()["Account"]
  749. try:
  750. role = iam.get_role(RoleName=role_name)
  751. return role["Role"]["Arn"]
  752. except iam.exceptions.NoSuchEntityException:
  753. create_role_response = iam.create_role(
  754. Description=(
  755. "Role used by the s3-credentials tool to create time-limited "
  756. "credentials that are restricted to specific buckets"
  757. ),
  758. RoleName=role_name,
  759. AssumeRolePolicyDocument=json.dumps(
  760. {
  761. "Version": "2012-10-17",
  762. "Statement": [
  763. {
  764. "Effect": "Allow",
  765. "Principal": {
  766. "AWS": "arn:aws:iam::{}:root".format(account_id)
  767. },
  768. "Action": "sts:AssumeRole",
  769. }
  770. ],
  771. }
  772. ),
  773. )
  774. # Attach AmazonS3FullAccess to it - note that even though we use full access
  775. # on the role itself any time we call sts.assume_role() we attach an additional
  776. # policy to ensure reduced access for the temporary credentials
  777. iam.attach_role_policy(
  778. RoleName="s3-credentials.AmazonS3FullAccess",
  779. PolicyArn="arn:aws:iam::aws:policy/AmazonS3FullAccess",
  780. )
  781. return create_role_response["Role"]["Arn"]
  782. @cli.command()
  783. @click.argument("bucket")
  784. @click.option("--prefix", help="List keys starting with this prefix")
  785. @common_output_options
  786. @common_boto3_options
  787. def list_bucket(bucket, prefix, nl, csv, tsv, **boto_options):
  788. """
  789. List contents of bucket
  790. To list the contents of a bucket as JSON:
  791. s3-credentials list-bucket my-bucket
  792. Add --csv or --csv for CSV or TSV format:
  793. s3-credentials list-bucket my-bucket --csv
  794. """
  795. s3 = make_client("s3", **boto_options)
  796. kwargs = {"Bucket": bucket}
  797. if prefix:
  798. kwargs["Prefix"] = prefix
  799. try:
  800. output(
  801. paginate(s3, "list_objects_v2", "Contents", **kwargs),
  802. ("Key", "LastModified", "ETag", "Size", "StorageClass", "Owner"),
  803. nl,
  804. csv,
  805. tsv,
  806. )
  807. except botocore.exceptions.ClientError as e:
  808. raise click.ClickException(e)
  809. @cli.command()
  810. @click.argument("bucket")
  811. @click.argument("key")
  812. @click.argument(
  813. "path",
  814. type=click.Path(
  815. exists=True, file_okay=True, dir_okay=False, readable=True, allow_dash=True
  816. ),
  817. )
  818. @click.option(
  819. "--content-type",
  820. help="Content-Type to use (default is auto-detected based on file extension)",
  821. )
  822. @click.option("silent", "-s", "--silent", is_flag=True, help="Don't show progress bar")
  823. @common_boto3_options
  824. def put_object(bucket, key, path, content_type, silent, **boto_options):
  825. """
  826. Upload an object to an S3 bucket
  827. To upload a file to /my-key.txt in the my-bucket bucket:
  828. s3-credentials put-object my-bucket my-key.txt /path/to/file.txt
  829. Use - to upload content from standard input:
  830. echo "Hello" | s3-credentials put-object my-bucket hello.txt -
  831. """
  832. s3 = make_client("s3", **boto_options)
  833. size = None
  834. extra_args = {}
  835. if path == "-":
  836. # boto needs to be able to seek
  837. fp = io.BytesIO(sys.stdin.buffer.read())
  838. if not silent:
  839. size = fp.getbuffer().nbytes
  840. else:
  841. if not content_type:
  842. content_type = mimetypes.guess_type(path)[0]
  843. fp = click.open_file(path, "rb")
  844. if not silent:
  845. size = os.path.getsize(path)
  846. if content_type is not None:
  847. extra_args["ContentType"] = content_type
  848. if not silent:
  849. # Show progress bar
  850. with click.progressbar(length=size, label="Uploading") as bar:
  851. s3.upload_fileobj(
  852. fp, bucket, key, Callback=bar.update, ExtraArgs=extra_args
  853. )
  854. else:
  855. s3.upload_fileobj(fp, bucket, key, ExtraArgs=extra_args)
  856. @cli.command()
  857. @click.argument("bucket")
  858. @click.argument("key")
  859. @click.option(
  860. "output",
  861. "-o",
  862. "--output",
  863. type=click.Path(file_okay=True, dir_okay=False, writable=True, allow_dash=False),
  864. help="Write to this file instead of stdout",
  865. )
  866. @common_boto3_options
  867. def get_object(bucket, key, output, **boto_options):
  868. """
  869. Download an object from an S3 bucket
  870. To see the contents of the bucket on standard output:
  871. s3-credentials get-object my-bucket hello.txt
  872. To save to a file:
  873. s3-credentials get-object my-bucket hello.txt -o hello.txt
  874. """
  875. s3 = make_client("s3", **boto_options)
  876. if not output:
  877. fp = sys.stdout.buffer
  878. else:
  879. fp = click.open_file(output, "wb")
  880. s3.download_fileobj(bucket, key, fp)
  881. @cli.command()
  882. @click.argument("bucket")
  883. @click.option(
  884. "allowed_methods",
  885. "-m",
  886. "--allowed-method",
  887. multiple=True,
  888. help="Allowed method e.g. GET",
  889. )
  890. @click.option(
  891. "allowed_headers",
  892. "-h",
  893. "--allowed-header",
  894. multiple=True,
  895. help="Allowed header e.g. Authorization",
  896. )
  897. @click.option(
  898. "allowed_origins",
  899. "-o",
  900. "--allowed-origin",
  901. multiple=True,
  902. help="Allowed origin e.g. https://www.example.com/",
  903. )
  904. @click.option(
  905. "expose_headers",
  906. "-e",
  907. "--expose-header",
  908. multiple=True,
  909. help="Header to expose e.g. ETag",
  910. )
  911. @click.option(
  912. "max_age_seconds",
  913. "--max-age-seconds",
  914. type=int,
  915. help="How long to cache preflight requests",
  916. )
  917. @common_boto3_options
  918. def set_cors_policy(
  919. bucket,
  920. allowed_methods,
  921. allowed_headers,
  922. allowed_origins,
  923. expose_headers,
  924. max_age_seconds,
  925. **boto_options
  926. ):
  927. """
  928. Set CORS policy for a bucket
  929. To allow GET requests from any origin:
  930. s3-credentials set-cors-policy my-bucket
  931. To allow GET and PUT from a specific origin and expose ETag headers:
  932. \b
  933. s3-credentials set-cors-policy my-bucket \\
  934. --allowed-method GET \\
  935. --allowed-method PUT \\
  936. --allowed-origin https://www.example.com/ \\
  937. --expose-header ETag
  938. """
  939. s3 = make_client("s3", **boto_options)
  940. if not bucket_exists(s3, bucket):
  941. raise click.ClickException("Bucket {} does not exists".format(bucket))
  942. cors_rule = {
  943. "ID": "set-by-s3-credentials",
  944. "AllowedOrigins": allowed_origins or ["*"],
  945. "AllowedHeaders": allowed_headers,
  946. "AllowedMethods": allowed_methods or ["GET"],
  947. "ExposeHeaders": expose_headers,
  948. }
  949. if max_age_seconds:
  950. cors_rule["MaxAgeSeconds"] = max_age_seconds
  951. try:
  952. s3.put_bucket_cors(Bucket=bucket, CORSConfiguration={"CORSRules": [cors_rule]})
  953. except botocore.exceptions.ClientError as e:
  954. raise click.ClickException(e)
  955. @cli.command()
  956. @click.argument("bucket")
  957. @common_boto3_options
  958. def get_cors_policy(bucket, **boto_options):
  959. """
  960. Get CORS policy for a bucket
  961. s3-credentials get-cors-policy my-bucket
  962. Returns the CORS policy for this bucket, if set, as JSON
  963. """
  964. s3 = make_client("s3", **boto_options)
  965. try:
  966. response = s3.get_bucket_cors(Bucket=bucket)
  967. except botocore.exceptions.ClientError as e:
  968. raise click.ClickException(e)
  969. click.echo(json.dumps(response["CORSRules"], indent=4, default=str))
  970. def output(iterator, headers, nl, csv, tsv):
  971. if nl:
  972. for item in iterator:
  973. click.echo(json.dumps(item, default=str))
  974. elif csv or tsv:
  975. writer = DictWriter(
  976. sys.stdout, headers, dialect="excel-tab" if tsv else "excel"
  977. )
  978. writer.writeheader()
  979. writer.writerows(fix_json(row) for row in iterator)
  980. else:
  981. for line in stream_indented_json(iterator):
  982. click.echo(line)
  983. def stream_indented_json(iterator, indent=2):
  984. # We have to iterate two-at-a-time so we can know if we
  985. # should output a trailing comma or if we have reached
  986. # the last item.
  987. current_iter, next_iter = itertools.tee(iterator, 2)
  988. next(next_iter, None)
  989. first = True
  990. for item, next_item in itertools.zip_longest(current_iter, next_iter):
  991. is_last = next_item is None
  992. data = item
  993. line = "{first}{serialized}{separator}{last}".format(
  994. first="[\n" if first else "",
  995. serialized=textwrap.indent(
  996. json.dumps(data, indent=indent, default=str), " " * indent
  997. ),
  998. separator="," if not is_last else "",
  999. last="\n]" if is_last else "",
  1000. )
  1001. yield line
  1002. first = False
  1003. if first:
  1004. # We didn't output anything, so yield the empty list
  1005. yield "[]"
  1006. def paginate(service, method, list_key, **kwargs):
  1007. paginator = service.get_paginator(method)
  1008. for response in paginator.paginate(**kwargs):
  1009. yield from response[list_key]
  1010. def fix_json(row):
  1011. # If a key value is list or dict, json encode it
  1012. return dict(
  1013. [
  1014. (
  1015. key,
  1016. json.dumps(value, indent=2, default=str)
  1017. if isinstance(value, (dict, list, tuple))
  1018. else value,
  1019. )
  1020. for key, value in row.items()
  1021. ]
  1022. )