From 7fb4db197820851db353a90d38499ece488e73d2 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 19 Jan 2022 11:44:30 -0800 Subject: [PATCH] list-roles command * list-roles command, closes #61 * Refactor list-roles to use paginate() from #63 * Tests for list-roles - a whole lot of mocks * Documentation for list-roles * Fixed JSON output in --csv and --tsv --- README.md | 117 +++++++++++++++++++++++++++++++++++ s3_credentials/cli.py | 88 +++++++++++++++++++++++++- tests/test_s3_credentials.py | 98 +++++++++++++++++++++++++++++ 3 files changed, 302 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a3c480f..fcf7f0a 100644 --- a/README.md +++ b/README.md @@ -372,6 +372,123 @@ You can pass any number of usernames here. If you don't specify a username the t s3-credentials list-user-policies +### list-roles + +The `list-roles` command lists all of the roles available for the authenticated account. + +Add `--details` to fetch the inline and attached managed policies for each row as well - this is slower as it needs to make several additional API calls for each role. + +You can optionally add one or more role names to the command to display and fetch details about just those specific roles. + +Example usage: + +``` +% s3-credentials list-roles AWSServiceRoleForLightsail --details +[ + { + "Path": "/aws-service-role/lightsail.amazonaws.com/", + "RoleName": "AWSServiceRoleForLightsail", + "RoleId": "AROAWXFXAIOZG5ACQ5NZ5", + "Arn": "arn:aws:iam::462092780466:role/aws-service-role/lightsail.amazonaws.com/AWSServiceRoleForLightsail", + "CreateDate": "2021-01-15 21:41:48+00:00", + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": "lightsail.amazonaws.com" + }, + "Action": "sts:AssumeRole" + } + ] + }, + "MaxSessionDuration": 3600, + "inline_policies": [ + { + "RoleName": "AWSServiceRoleForLightsail", + "PolicyName": "LightsailExportAccess", + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "kms:Decrypt", + "kms:DescribeKey", + "kms:CreateGrant" + ], + "Resource": "arn:aws:kms:*:451833091580:key/*" + }, + { + "Effect": "Allow", + "Action": [ + "cloudformation:DescribeStacks" + ], + "Resource": "arn:aws:cloudformation:*:*:stack/*/*" + } + ] + } + } + ], + "attached_policies": [ + { + "PolicyName": "LightsailExportAccess", + "PolicyId": "ANPAJ4LZGPQLZWMVR4WMQ", + "Arn": "arn:aws:iam::aws:policy/aws-service-role/LightsailExportAccess", + "Path": "/aws-service-role/", + "DefaultVersionId": "v2", + "AttachmentCount": 1, + "PermissionsBoundaryUsageCount": 0, + "IsAttachable": true, + "Description": "AWS Lightsail service linked role policy which grants permissions to export resources", + "CreateDate": "2018-09-28 16:35:54+00:00", + "UpdateDate": "2022-01-15 01:45:33+00:00", + "Tags": [], + "PolicyVersion": { + "Document": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "iam:DeleteServiceLinkedRole", + "iam:GetServiceLinkedRoleDeletionStatus" + ], + "Resource": "arn:aws:iam::*:role/aws-service-role/lightsail.amazonaws.com/AWSServiceRoleForLightsail*" + }, + { + "Effect": "Allow", + "Action": [ + "ec2:CopySnapshot", + "ec2:DescribeSnapshots", + "ec2:CopyImage", + "ec2:DescribeImages" + ], + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": [ + "s3:GetAccountPublicAccessBlock" + ], + "Resource": "*" + } + ] + }, + "VersionId": "v2", + "IsDefaultVersion": true, + "CreateDate": "2022-01-15 01:45:33+00:00" + } + } + ] + } +] +``` +Add `--nl` to collapse these to single lines as valid newline-delimited JSON. + +Add `--csv` or `--tsv` to get back CSV or TSV data. + ### delete-user In trying out this tool it's possible you will create several different user accounts that you later decide to clean up. diff --git a/s3_credentials/cli.py b/s3_credentials/cli.py index ba2d76c..3426be2 100644 --- a/s3_credentials/cli.py +++ b/s3_credentials/cli.py @@ -503,6 +503,77 @@ def list_users(nl, csv, tsv, **boto_options): ) +@cli.command() +@click.argument("role_names", nargs=-1) +@click.option("--details", help="Include attached policies (slower)", is_flag=True) +@common_output_options +@common_boto3_options +def list_roles(role_names, details, nl, csv, tsv, **boto_options): + "List all roles" + iam = make_client("iam", **boto_options) + headers = ( + "Path", + "RoleName", + "RoleId", + "Arn", + "CreateDate", + "AssumeRolePolicyDocument", + "Description", + "MaxSessionDuration", + "PermissionsBoundary", + "Tags", + "RoleLastUsed", + ) + if details: + headers += ("inline_policies", "attached_policies") + + def iterate(): + for role in paginate(iam, "list_roles", "Roles"): + if role_names and role["RoleName"] not in role_names: + continue + if details: + role_name = role["RoleName"] + role["inline_policies"] = [] + # Get inline policy names, then policy for each one + for policy_name in paginate( + iam, "list_role_policies", "PolicyNames", RoleName=role_name + ): + role_policy_response = iam.get_role_policy( + RoleName=role_name, + PolicyName=policy_name, + ) + role_policy_response.pop("ResponseMetadata", None) + role["inline_policies"].append(role_policy_response) + + # Get attached managed policies + role["attached_policies"] = [] + for attached in paginate( + iam, + "list_attached_role_policies", + "AttachedPolicies", + RoleName=role_name, + ): + policy_arn = attached["PolicyArn"] + attached_policy_response = iam.get_policy( + PolicyArn=policy_arn, + ) + policy_details = attached_policy_response["Policy"] + # Also need to fetch the policy JSON + version_id = policy_details["DefaultVersionId"] + policy_version_response = iam.get_policy_version( + PolicyArn=policy_arn, + VersionId=version_id, + ) + policy_details["PolicyVersion"] = policy_version_response[ + "PolicyVersion" + ] + role["attached_policies"].append(policy_details) + + yield role + + output(iterate(), headers, nl, csv, tsv) + + @cli.command() @click.argument("usernames", nargs=-1) @common_boto3_options @@ -782,7 +853,7 @@ def output(iterator, headers, nl, csv, tsv): sys.stdout, headers, dialect="excel-tab" if tsv else "excel" ) writer.writeheader() - writer.writerows(iterator) + writer.writerows(fix_json(row) for row in iterator) else: for line in stream_indented_json(iterator): click.echo(line) @@ -817,3 +888,18 @@ def paginate(service, method, list_key, **kwargs): paginator = service.get_paginator(method) for response in paginator.paginate(**kwargs): yield from response[list_key] + + +def fix_json(row): + # If a key value is list or dict, json encode it + return dict( + [ + ( + key, + json.dumps(value, indent=2, default=str) + if isinstance(value, (dict, list, tuple)) + else value, + ) + for key, value in row.items() + ] + ) diff --git a/tests/test_s3_credentials.py b/tests/test_s3_credentials.py index 059defa..6fe2e00 100644 --- a/tests/test_s3_credentials.py +++ b/tests/test_s3_credentials.py @@ -805,3 +805,101 @@ def test_list_bucket(stub_s3, options, expected): result = runner.invoke(cli, ["list-bucket", "test-bucket"] + options) assert result.exit_code == 0 assert result.output == expected + + +@pytest.fixture +def stub_iam_for_list_roles(stub_iam): + stub_iam.add_response( + "list_roles", + { + "Roles": [ + { + "RoleName": "role-one", + "Path": "/", + "Arn": "arn:aws:iam::462092780466:role/role-one", + "RoleId": "36b2eeee501c5952a8ac119f9e521", + "CreateDate": "2020-01-01 00:00:00+00:00", + } + ] + }, + ) + stub_iam.add_response( + "list_role_policies", + {"PolicyNames": ["policy-one"]}, + ) + stub_iam.add_response( + "get_role_policy", + { + "RoleName": "role-one", + "PolicyName": "policy-one", + "PolicyDocument": '{"foo": "bar}', + }, + ) + stub_iam.add_response( + "list_attached_role_policies", + {"AttachedPolicies": [{"PolicyArn": "arn:123:must-be-at-least-tweny-chars"}]}, + ) + stub_iam.add_response( + "get_policy", + {"Policy": {"DefaultVersionId": "v1"}}, + ) + stub_iam.add_response( + "get_policy_version", + {"PolicyVersion": {"CreateDate": "2020-01-01 00:00:00+00:00"}}, + ) + + +@pytest.mark.parametrize("details", (False, True)) +def test_list_roles_details(stub_iam_for_list_roles, details): + runner = CliRunner() + with runner.isolated_filesystem(): + result = runner.invoke(cli, ["list-roles"] + (["--details"] if details else [])) + assert result.exit_code == 0 + expected = { + "RoleName": "role-one", + "Path": "/", + "Arn": "arn:aws:iam::462092780466:role/role-one", + "RoleId": "36b2eeee501c5952a8ac119f9e521", + "CreateDate": "2020-01-01 00:00:00+00:00", + "inline_policies": [ + { + "RoleName": "role-one", + "PolicyName": "policy-one", + "PolicyDocument": '{"foo": "bar}', + } + ], + "attached_policies": [ + { + "DefaultVersionId": "v1", + "PolicyVersion": {"CreateDate": "2020-01-01 00:00:00+00:00"}, + } + ], + } + if not details: + expected.pop("inline_policies") + expected.pop("attached_policies") + assert json.loads(result.output) == [expected] + + +def test_list_roles_csv(stub_iam_for_list_roles): + runner = CliRunner() + with runner.isolated_filesystem(): + result = runner.invoke(cli, ["list-roles", "--csv", "--details"]) + assert result.exit_code == 0 + assert result.output == ( + "Path,RoleName,RoleId,Arn,CreateDate,AssumeRolePolicyDocument,Description,MaxSessionDuration,PermissionsBoundary,Tags,RoleLastUsed,inline_policies,attached_policies\n" + '/,role-one,36b2eeee501c5952a8ac119f9e521,arn:aws:iam::462092780466:role/role-one,2020-01-01 00:00:00+00:00,,,,,,,"[\n' + " {\n" + ' ""RoleName"": ""role-one"",\n' + ' ""PolicyName"": ""policy-one"",\n' + ' ""PolicyDocument"": ""{\\""foo\\"": \\""bar}""\n' + " }\n" + ']","[\n' + " {\n" + ' ""DefaultVersionId"": ""v1"",\n' + ' ""PolicyVersion"": {\n' + ' ""CreateDate"": ""2020-01-01 00:00:00+00:00""\n' + " }\n" + " }\n" + ']"\n' + )