From 24a8b0ad9aef12aaec6dec62796a856875679e6e Mon Sep 17 00:00:00 2001 From: Anton <125114167+EreminAnton@users.noreply.github.com> Date: Fri, 6 Sep 2024 11:18:49 +0500 Subject: [PATCH] Group assignments (#95) * feat: group access (dump work in progress) * feat: allow prividing config by terraform * feat: add a way to approve requests * refactor: more handle errors func to the errors.py * refactoring: move sso to the sso.py & add more type hints * fix: import func from sso * refactoring: use one "show initial form func, and move request handling to main.py * cleanup: rm unused * feat: refactor build_approval_request_message_blocks to work with both group's and account access * feat: use build_initial_form_handler to show initial form fo both types of requests * fix: import build_approval_request_message_blocks from slack_helpers & rm unused * fmt: rm unused, refactor & more logs * fix: run codeguru only in main branch commits * refactor: move schedule creation to the schedule.py * fmt: rm unused * feat: rename execute_decision to execute_decision_on_group_request * feat: handle if user is already in the group * feat: more refactorings & rename test.py to group.py * feat: use default sesion * fix: NA if response is unbound * fix: import RequestForGroupAccessView from slack_helpers * fix: create response anyway, and use get to get value * feat: handle execute_decision_on_group_request logs better * fix: pass correct event to the get_and_delete_scheduled_revoke_event_if_already_exist * feat: add more logs to get_and_delete_scheduled_revoke_event_if_already_exist * fix: provide membership_id in any case * fix: add group_revoke_event to the get_scheduled_events * feat: more logs for get_scheduled_events * fix: get right type of events * fix: use right class in get_and_delete_scheduled_revoke_event_if_already_exist * feat: GroupMembership class * feat: more logs & get_groups_from_config, not all groups * feat: refactor is_user_in_group to more easely reuse list_group_memberships * feat: get group assignments from config * feat: rm unused, and fix import * feat: check on inconsistency & scheduler revokation * feat: get groups from statements & more logs * fix: add check_on_groups_inconsistency & handle_sso_elevator_group_scheduled_revocation to event handlers * fix: rm unneeded indent * feat: add GROUP_STATEMENTS to revoker config * fix: rm unused * feat: use new version of audit entry * feat: raise error if both configs are not provided * feat: upd readme * fmt: precommit run -a * feat: use aware datetimes to represent times in UTC. * feat: fail if message is none on ButtonClickedPayload validations * fix: try fix pytest ci by bypassing config validation * fix: try fix ci * fix: try fix config * fix: fix config testing * fIx: try fix config tests * feat: add tests for group_config * fix: rm unused * fix: generate group id properly * fix: use correct group id format * fix: rm * from group test * fix: add optional part to group config id testing * fix: try fix config ci * fix: less examples: to slow * fix: more group id parts * fix: increase deadline * fix: supress HealthCheck.too_slow * fix: disable HealthCheck.too_slow for individual tests --- README.md | 115 +++++++++-- perm_revoker_lambda.tf | 11 + slack_handler_lambda.tf | 11 + src/access_control.py | 115 +++++++++-- src/config.py | 34 +++- src/deploy_requirements.txt | 165 +++++++-------- src/entities/aws.py | 14 ++ src/errors.py | 38 ++++ src/events.py | 25 ++- src/group.py | 230 +++++++++++++++++++++ src/main.py | 87 ++++---- src/requirements.txt | 388 +++++++++++++++++++----------------- src/revoker.py | 210 ++++++++++++++++++- src/s3.py | 33 +-- src/schedule.py | 72 ++++++- src/slack_helpers.py | 210 +++++++++++++++++-- src/sso.py | 133 +++++++++++- src/statement.py | 18 ++ src/tests/conftest.py | 24 ++- src/tests/strategies.py | 45 ++++- src/tests/test_config.py | 56 +++++- vars.tf | 5 + 22 files changed, 1634 insertions(+), 405 deletions(-) create mode 100644 src/group.py diff --git a/README.md b/README.md index 62ed03b..a78263f 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ - [Terraform module for implementing temporary elevated access via AWS IAM Identity Center (Successor to AWS Single Sign-On) and Slack](#terraform-module-for-implementing-temporary-elevated-access-via-aws-iam-identity-center-successor-to-aws-single-sign-on-and-slack) - [Introduction](#introduction) - [Functionality](#functionality) + - [Group Assignments Mode](#group-assignments-mode) - [Important Considerations and Assumptions](#important-considerations-and-assumptions) - [Deployment and Usage](#deployment-and-usage) - [Note on dependencies](#note-on-dependencies) @@ -69,6 +70,50 @@ For auditing purposes, information about all access grants and revocations is st Additionally, the Access-Revoker continuously reconciles the revocation schedule with all user-level permission set assignments and issues warnings if it detects assignments without a revocation schedule (presumably created by someone manually). By default, the Access-Revoker will automatically revoke all unknown user-level permission set assignments daily. However, you can configure it to operate more or less frequently. +## Group Assignments Mode +Starting from version 2.0, Terraform AWS SSO Elevator introduces support for group access. Users can now use the /group-access command, which, instead of showing the form for account assignments, will present a Slack form where the user can select a group they want access to, specify a reason, and define the duration for which access is required. + +The basic logic for access, configuration, and Slack integration remains the same as before. To enable the new Group Assignments Mode, you need to provide the module with a new group_config Terraform variable: +```hcl +group_config = [ + { + "Resource" : ["99999999-8888-7777-6666-555555555555"], #ManagementAccountAdmins + "Approvers" : [ + "email@gmail.com" + ] + "ApprovalIsNotRequired": true + }, + { + "Resource" : ["11111111-2222-3333-4444-555555555555"], #prod read only + "Approvers" : [ + "email@gmail.com" + ] + "AllowSelfApproval" : true, + }, + { + "Resource" : ["44445555-3333-2222-1111-555557777777"], #ProdAdminAccess + "Approvers" : [ + "email@gmail.com" + ] + }, +] +``` +There are two key differences compared to the standard Elevator configuration: +- ResourceType is not required for group access configurations. +- In the Resource field, you must provide group IDs instead of account IDs. + +The Elevator will only work with groups specified in the configuration. + +If you were using Terraform AWS SSO Elevator before version 2.0.0, you need to update your Slack app manifest by adding a new shortcut to enable this functionality: +{ + "name": "group-access", + "type": "global", + "callback_id": "request_for_group_membership", + "description": "Request access to SSO Group" +} +To disable this functionality, simply remove the shortcut from the manifest. + + # Important Considerations and Assumptions SSO elevator assumes that your Slack user email will match SSO user id otherwise it won't be able to match Slack user sending request to an AWS SSO user. @@ -94,20 +139,20 @@ ECR is private for the following reasons: Images and repositories are replicated in every region that AWS SSO supports exept these: ``` -# ap_east_1 -# eu_south_1 -# ap_southeast_3 -# af_south_1 -# me_south_1 -# il_central_1 -# me_central_1 -# eu_south_2 -# ap_south_2 -# eu_central_2 -# ap_southeast_4 -# ca_west_1 -# us_gov_east_1 -# us_gov_west_1 +- ap_east_1 +- eu_south_1 +- ap_southeast_3 +- af_south_1 +- me_south_1 +- il_central_1 +- me_central_1 +- eu_south_2 +- ap_south_2 +- eu_central_2 +- ap_southeast_4 +- ca_west_1 +- us_gov_east_1 +- us_gov_west_1 ``` Those regions are not enabled by deafult. If you need to use a region that is not supported by the module, please let us know by creating an issue, and we will add support for it. @@ -327,6 +372,28 @@ module "aws_sso_elevator" { }, ] +group_config = [ + { + "Resource" : ["99999999-8888-7777-6666-555555555555"], #ManagementAccountAdmins + "Approvers" : [ + "email@gmail.com" + ] + "ApprovalIsNotRequired": true + }, + { + "Resource" : ["11111111-2222-3333-4444-555555555555"], #prod read only + "Approvers" : [ + "email@gmail.com" + ] + "AllowSelfApproval" : true, + }, + { + "Resource" : ["44445555-3333-2222-1111-555557777777"], #ProdAdminAccess + "Approvers" : [ + "email@gmail.com" + ] + }, +] } output "aws_sso_elevator_lambda_function_url" { @@ -354,6 +421,10 @@ features: type: global callback_id: request_for_access description: Request access to Permission Set in AWS Account + - name: group-access # Delete this shortcut if you want to prohibit access to the Group Assignments Mode + type: global + callback_id: request_for_group_membership + description: Request access to SSO Group oauth_config: scopes: bot: @@ -397,7 +468,7 @@ settings: | Name | Version | |------|---------| -| [aws](#provider\_aws) | 5.56.1 | +| [aws](#provider\_aws) | 5.65.0 | | [random](#provider\_random) | 3.6.2 | ## Modules @@ -421,6 +492,7 @@ settings: | [aws_iam_role.eventbridge_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | | [aws_iam_role_policy.eventbridge_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | | [aws_lambda_permission.eventbridge](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | resource | +| [aws_lambda_permission.url](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | resource | | [aws_scheduler_schedule_group.one_time_schedule_group](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/scheduler_schedule_group) | resource | | [aws_sns_topic.dlq](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sns_topic) | resource | | [aws_sns_topic_subscription.dlq](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sns_topic_subscription) | resource | @@ -439,9 +511,13 @@ settings: | [approver\_renotification\_initial\_wait\_time](#input\_approver\_renotification\_initial\_wait\_time) | The initial wait time before the first re-notification to the approver is sent. This is measured in minutes. If set to 0, no re-notifications will be sent. | `number` | `15` | no | | [aws\_sns\_topic\_subscription\_email](#input\_aws\_sns\_topic\_subscription\_email) | value for the email address to subscribe to the SNS topic | `string` | `""` | no | | [config](#input\_config) | value for the SSO Elevator config | `any` | n/a | yes | -| [ecr\_owner\_account\_id](#input\_ecr\_owner\_account\_id) | In what account is the ECR repository located. | `string` | `"754426185857"` | no | -| [event\_brige\_check\_on\_inconsistency\_rule\_name](#input\_event\_brige\_check\_on\_inconsistency\_rule\_name) | value for the event bridge check on inconsistency rule name | `string` | `"sso_elevator_check_on_inconsistency"` | no | -| [event\_brige\_scheduled\_revocation\_rule\_name](#input\_event\_brige\_scheduled\_revocation\_rule\_name) | value for the event bridge scheduled revocation rule name | `string` | `"sso_elevator_scheduled_revocation"` | no | +| [create\_api\_gateway](#input\_create\_api\_gateway) | If true, module will create & configure API Gateway for the Lambda function | `bool` | `true` | no | +| [create\_lambda\_url](#input\_create\_lambda\_url) | If true, the Lambda function will continue to use the Lambda URL, which will be deprecated in the future
If false, Lambda url will be deleted. | `bool` | `true` | no | +| [ecr\_owner\_account\_id](#input\_ecr\_owner\_account\_id) | In what account is the ECR repository located. | `string` | `"222341826240"` | no | +| [ecr\_repo\_name](#input\_ecr\_repo\_name) | The name of the ECR repository. | `string` | `"aws-sso-elevator"` | no | +| [event\_brige\_check\_on\_inconsistency\_rule\_name](#input\_event\_brige\_check\_on\_inconsistency\_rule\_name) | value for the event bridge check on inconsistency rule name | `string` | `"sso-elevator-check_on-inconsistency"` | no | +| [event\_brige\_scheduled\_revocation\_rule\_name](#input\_event\_brige\_scheduled\_revocation\_rule\_name) | value for the event bridge scheduled revocation rule name | `string` | `"sso-elevator-scheduled-revocation"` | no | +| [group\_config](#input\_group\_config) | value for the SSO Elevator group config | `any` | n/a | yes | | [log\_level](#input\_log\_level) | value for the log level | `string` | `"INFO"` | no | | [max\_permissions\_duration\_time](#input\_max\_permissions\_duration\_time) | Maximum duration of the permissions granted by the Elevator in hours. | `number` | `24` | no | | [request\_expiration\_hours](#input\_request\_expiration\_hours) | After how many hours should the request expire? If set to 0, the request will never expire. | `number` | `8` | no | @@ -458,7 +534,7 @@ settings: | [schedule\_expression](#input\_schedule\_expression) | recovation schedule expression (will revoke all user-level assignments unknown to the Elevator) | `string` | `"cron(0 23 * * ? *)"` | no | | [schedule\_expression\_for\_check\_on\_inconsistency](#input\_schedule\_expression\_for\_check\_on\_inconsistency) | how often revoker should check for inconsistency (warn if found unknown user-level assignments) | `string` | `"rate(2 hours)"` | no | | [schedule\_group\_name](#input\_schedule\_group\_name) | value for the schedule group name | `string` | `"sso-elevator-scheduled-revocation"` | no | -| [schedule\_role\_name](#input\_schedule\_role\_name) | value for the schedule role name | `string` | `"event-bridge-role-for-sso-elevator"` | no | +| [schedule\_role\_name](#input\_schedule\_role\_name) | value for the schedule role name | `string` | `"sso-elevator-event-bridge-role"` | no | | [slack\_bot\_token](#input\_slack\_bot\_token) | value for the Slack bot token | `string` | n/a | yes | | [slack\_channel\_id](#input\_slack\_channel\_id) | value for the Slack channel ID | `string` | n/a | yes | | [slack\_signing\_secret](#input\_slack\_signing\_secret) | value for the Slack signing secret | `string` | n/a | yes | @@ -470,6 +546,7 @@ settings: | Name | Description | |------|-------------| +| [lambda\_function\_url](#output\_lambda\_function\_url) | value for the access\_requester lambda function URL | | [requester\_api\_endpoint\_url](#output\_requester\_api\_endpoint\_url) | The full URL to invoke the API. Pass this URL into the Slack App manifest as the Request URL. | | [sso\_elevator\_bucket\_id](#output\_sso\_elevator\_bucket\_id) | The name of the SSO elevator bucket. | diff --git a/perm_revoker_lambda.tf b/perm_revoker_lambda.tf index c4cc3a0..7153e3b 100644 --- a/perm_revoker_lambda.tf +++ b/perm_revoker_lambda.tf @@ -51,6 +51,7 @@ module "access_revoker" { SSO_INSTANCE_ARN = local.sso_instance_arn STATEMENTS = jsonencode(var.config) + GROUP_STATEMENTS = jsonencode(var.group_config) POWERTOOLS_LOGGER_LOG_EVENT = true POST_UPDATE_TO_SLACK = var.revoker_post_update_to_slack @@ -155,6 +156,16 @@ data "aws_iam_policy_document" "revoker" { ] resources = ["${local.s3_bucket_arn}/${var.s3_bucket_partition_prefix}/*"] } + statement { + effect = "Allow" + actions = [ + "identitystore:ListGroups", + "identitystore:DescribeGroup", + "identitystore:ListGroupMemberships", + "identitystore:DeleteGroupMembership" + ] + resources = ["*"] + } } resource "aws_cloudwatch_event_rule" "sso_elevator_scheduled_revocation" { diff --git a/slack_handler_lambda.tf b/slack_handler_lambda.tf index 843da30..3790b1a 100644 --- a/slack_handler_lambda.tf +++ b/slack_handler_lambda.tf @@ -52,6 +52,7 @@ module "access_requester_slack_handler" { SSO_INSTANCE_ARN = local.sso_instance_arn STATEMENTS = jsonencode(var.config) + GROUP_STATEMENTS = jsonencode(var.group_config) POWERTOOLS_LOGGER_LOG_EVENT = true SCHEDULE_POLICY_ARN = aws_iam_role.eventbridge_role.arn REVOKER_FUNCTION_ARN = local.revoker_lambda_arn @@ -206,6 +207,16 @@ data "aws_iam_policy_document" "slack_handler" { ] resources = ["*"] } + statement { + effect = "Allow" + actions = [ + "identitystore:ListGroups", + "identitystore:DescribeGroup", + "identitystore:ListGroupMemberships", + "identitystore:CreateGroupMembership", + ] + resources = ["*"] + } } module "http_api" { diff --git a/src/access_control.py b/src/access_control.py index 568f946..5132755 100644 --- a/src/access_control.py +++ b/src/access_control.py @@ -10,7 +10,7 @@ import schedule import sso from entities import BaseModel -from statement import Statement, get_affected_statements +from statement import GroupStatement, Statement, get_affected_group_statements, get_affected_statements logger = config.get_logger("access_control") cfg = config.get_config() @@ -33,18 +33,38 @@ class DecisionReason(Enum): class AccessRequestDecision(BaseModel): grant: bool reason: DecisionReason - based_on_statements: FrozenSet[Statement] + based_on_statements: FrozenSet[Statement] | FrozenSet[GroupStatement] approvers: FrozenSet[str] = frozenset() +def determine_affected_statements( + statements: FrozenSet[Statement] | FrozenSet[GroupStatement], + account_id: str | None = None, + permission_set_name: str | None = None, + group_id: str | None = None, +) -> FrozenSet[Statement] | FrozenSet[GroupStatement]: + if isinstance(statements, FrozenSet) and all(isinstance(item, Statement) for item in statements): + return get_affected_statements(statements, account_id, permission_set_name) # type: ignore # noqa: PGH003 + + if isinstance(statements, FrozenSet) and all(isinstance(item, GroupStatement) for item in statements): + return get_affected_group_statements(statements, group_id) # type: ignore # noqa: PGH003 + + # About type ignore: + # For some reason, pylance is not able to understand that we already checked the type of the items in the set, + # and shows a type error for "statements" + raise TypeError("Statements contain mixed or unsupported types.") + + def make_decision_on_access_request( # noqa: PLR0911 - statements: FrozenSet[Statement], - permission_set_name: str, - account_id: str, + statements: FrozenSet[Statement] | FrozenSet[GroupStatement], requester_email: str, + permission_set_name: str | None = None, + account_id: str | None = None, + group_id: str | None = None, ) -> AccessRequestDecision: - affected_statements = get_affected_statements(statements, account_id, permission_set_name) - decision_based_on_statements: set[Statement] = set() + affected_statements = determine_affected_statements(statements, account_id, permission_set_name, group_id) + + decision_based_on_statements: set[Statement] | set[GroupStatement] = set() potential_approvers = set() explicit_deny_self_approval = any( @@ -57,16 +77,16 @@ def make_decision_on_access_request( # noqa: PLR0911 return AccessRequestDecision( grant=True, reason=DecisionReason.ApprovalNotRequired, - based_on_statements=frozenset([statement]), + based_on_statements=frozenset([statement]), # type: ignore # noqa: PGH003 ) if requester_email in statement.approvers and statement.allow_self_approval and not explicit_deny_self_approval: return AccessRequestDecision( grant=True, reason=DecisionReason.SelfApproval, - based_on_statements=frozenset([statement]), + based_on_statements=frozenset([statement]), # type: ignore # noqa: PGH003 ) - decision_based_on_statements.add(statement) + decision_based_on_statements.add(statement) # type: ignore # noqa: PGH003 potential_approvers.update(approver for approver in statement.approvers if approver != requester_email) if not decision_based_on_statements: @@ -101,18 +121,19 @@ class ApproveRequestDecision(BaseModel): grant: bool permit: bool - based_on_statements: FrozenSet[Statement] + based_on_statements: FrozenSet[Statement] | FrozenSet[GroupStatement] def make_decision_on_approve_request( # noqa: PLR0913 action: entities.ApproverAction, statements: frozenset[Statement], - permission_set_name: str, - account_id: str, approver_email: str, requester_email: str, + permission_set_name: str | None = None, + account_id: str | None = None, + group_id: str | None = None, ) -> ApproveRequestDecision: - affected_statements = get_affected_statements(statements, account_id, permission_set_name) + affected_statements = determine_affected_statements(statements, account_id, permission_set_name, group_id) for statement in affected_statements: if approver_email in statement.approvers: @@ -121,13 +142,13 @@ def make_decision_on_approve_request( # noqa: PLR0913 return ApproveRequestDecision( grant=action == entities.ApproverAction.Approve, permit=True, - based_on_statements=frozenset([statement]), + based_on_statements=frozenset([statement]), # type: ignore # noqa: PGH003 ) return ApproveRequestDecision( grant=False, permit=False, - based_on_statements=affected_statements, + based_on_statements=affected_statements, # type: ignore # noqa: PGH003 ) @@ -174,6 +195,8 @@ def execute_decision( # noqa: PLR0913 request_id=account_assignment_status.request_id, operation_type="grant", permission_duration=permission_duration, + sso_user_principal_id=user_principal_id, + audit_entry_type="account", ), ) @@ -190,3 +213,63 @@ def execute_decision( # noqa: PLR0913 ), ) return True # Temporary solution for testing + + +def execute_decision_on_group_request( # noqa: PLR0913 + decision: AccessRequestDecision | ApproveRequestDecision, + group: entities.aws.SSOGroup, + user_principal_id: str, + permission_duration: datetime.timedelta, + approver: entities.slack.User, + requester: entities.slack.User, + reason: str, + identity_store_id: str, +) -> bool: + logger.info("Executing decision") + if not decision.grant: + logger.info("Access request denied") + return False # Temporary solution for testing + + if membership_id := sso.is_user_in_group( + identity_store_id=identity_store_id, + group_id=group.id, + sso_user_id=user_principal_id, + identity_store_client=identitystore_client, + ): + logger.info( + "User is already in the group", extra={"group_id": group.id, "user_id": user_principal_id, "membership_id": membership_id} + ) + else: + membership_id = sso.add_user_to_a_group(group.id, user_principal_id, identity_store_id, identitystore_client)["MembershipId"] + logger.info("User added to the group", extra={"group_id": group.id, "user_id": user_principal_id, "membership_id": membership_id}) + + s3.log_operation( + audit_entry=s3.AuditEntry( + group_name=group.name, + group_id=group.id, + reason=reason, + requester_slack_id=requester.id, + requester_email=requester.email, + approver_slack_id=approver.id, + approver_email=approver.email, + operation_type="grant", + permission_duration=permission_duration, + audit_entry_type="group", + sso_user_principal_id=user_principal_id, + ), + ) + + schedule.schedule_group_revoke_event( + permission_duration=permission_duration, + schedule_client=schedule_client, + approver=approver, + requester=requester, + group_assignment=sso.GroupAssignment( + identity_store_id=identity_store_id, + group_name=group.name, + group_id=group.id, + user_principal_id=user_principal_id, + membership_id=membership_id, + ), + ) + return # type: ignore # noqa: PGH003 diff --git a/src/config.py b/src/config.py index 69f45f1..3435509 100644 --- a/src/config.py +++ b/src/config.py @@ -5,7 +5,7 @@ from pydantic import BaseSettings, root_validator import entities -from statement import Statement +from statement import Statement, GroupStatement def parse_statement(_dict: dict) -> Statement: @@ -26,6 +26,26 @@ def to_set_if_list_or_str(v: list | str) -> frozenset[str]: ) +def parse_group_statement(_dict: dict) -> GroupStatement: + def to_set_if_list_or_str(v: list | str) -> frozenset[str]: + if isinstance(v, list): + return frozenset(v) + return frozenset([v]) if isinstance(v, str) else v + + return GroupStatement.parse_obj( + { + "resource": to_set_if_list_or_str(_dict["Resource"]), + "approvers": to_set_if_list_or_str(_dict.get("Approvers", set())), + "approval_is_not_required": _dict.get("ApprovalIsNotRequired"), + "allow_self_approval": _dict.get("AllowSelfApproval"), + } + ) + + +def get_groups_from_statements(statements: set[GroupStatement]) -> frozenset[str]: + return frozenset(group for statement in statements for group in statement.resource) + + class Config(BaseSettings): schedule_policy_arn: str revoker_function_arn: str @@ -44,9 +64,11 @@ class Config(BaseSettings): log_level: str = "INFO" slack_app_log_level: str = "INFO" statements: frozenset[Statement] + group_statements: frozenset[GroupStatement] accounts: frozenset[str] permission_sets: frozenset[str] + groups: frozenset[str] s3_bucket_for_audit_entry_name: str s3_bucket_prefix_for_partitions: str @@ -67,6 +89,14 @@ class Config: @root_validator(pre=True) def get_accounts_and_permission_sets(cls, values: dict) -> dict: # noqa: ANN101 statements = {parse_statement(st) for st in values.get("statements", [])} # type: ignore # noqa: PGH003 + group_statements = {parse_group_statement(st) for st in values.get("group_statements", [])} # type: ignore # noqa: PGH003 + if not group_statements and not statements: + raise ValueError( + """ + At least one type of config is requred, + please provide 'config' or 'group_config' variable to terraform module""" + ) + groups = get_groups_from_statements(group_statements) permission_sets = set() accounts = set() for statement in statements: @@ -77,6 +107,8 @@ def get_accounts_and_permission_sets(cls, values: dict) -> dict: # noqa: ANN101 "accounts": accounts, "permission_sets": permission_sets, "statements": frozenset(statements), + "group_statements": frozenset(group_statements), + "groups": groups, } diff --git a/src/deploy_requirements.txt b/src/deploy_requirements.txt index 77d3493..41bc7df 100644 --- a/src/deploy_requirements.txt +++ b/src/deploy_requirements.txt @@ -1,91 +1,98 @@ -boto3-stubs[dynamodb,events,identitystore,organizations,s3,scheduler,sso-admin]==1.28.4 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ - --hash=sha256:7a68d2a456d995318a6237eb5c6039f63b6a9bf902f216ff2261f87015fbb1c3 \ - --hash=sha256:a5b1cb7cdfd83f34b573c9af4eda80a62638f2ac78fae9c972721d3837e1b918 -botocore-stubs==1.31.4 ; python_full_version >= "3.10.10" and python_version < "4.0" \ - --hash=sha256:3fedfe390a3e8e9d6b287f9ef5bb5a706a1d0e4305881506f6889537894e9a90 \ - --hash=sha256:b52f570927621076304d3da6e77cef547e18b9d30f335630f39cc78d3452eeb0 +boto3-stubs[dynamodb,events,identitystore,organizations,s3,scheduler,sso-admin]==1.34.160 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ + --hash=sha256:7e499b74d53e8eb456e539b3c82ccb6b88038579e3434fb131d51a8abe11dc83 \ + --hash=sha256:c6b1dfeb3cae673eed596f01409339e2e0b955a5241a16cee69f29303d9b37de +botocore-stubs==1.34.160 ; python_full_version >= "3.10.10" and python_version < "4.0" \ + --hash=sha256:900953f3f926d205505776535fd131047ef89519734f1e5365d03ecbaec53cd9 \ + --hash=sha256:b16122567dbf0860a76960ea4b94a396f16ba1a6afb9577dcc11dcd55047c42b croniter==1.4.1 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ --hash=sha256:1a6df60eacec3b7a0aa52a8f2ef251ae3dd2a7c7c8b9874e73e791636d55a361 \ --hash=sha256:9595da48af37ea06ec3a9f899738f1b2c1c13da3c38cea606ef7cd03ea421128 dnspython==2.6.1 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ --hash=sha256:5ef3b9680161f6fa89daf8ad451b5f1a33b18ae8a1c6778cdf4b43f08c0a6e50 \ --hash=sha256:e8f0f9c23a7b7cb99ded64e6c3a6f3e701d78f50c55e002b839dea7225cff7cc -email-validator==2.0.0.post2 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ - --hash=sha256:1ff6e86044200c56ae23595695c54e9614f4a9551e0e393614f764860b3d7900 \ - --hash=sha256:2466ba57cda361fb7309fd3d5a225723c788ca4bbad32a0ebd5373b99730285c +email-validator==2.2.0 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ + --hash=sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631 \ + --hash=sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7 idna==3.7 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ --hash=sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc \ --hash=sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0 -mypy-boto3-dynamodb==1.28.0 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ - --hash=sha256:622f3d14dc1835a17ca511672d2f8fd08c03c4930f2845d06d1632b9f0c92aaf \ - --hash=sha256:d12ed66edd7ded7089297b533d77e8b8ed8844da4e097cd912b61a08bfe4948b -mypy-boto3-events==1.28.0 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ - --hash=sha256:b7e362c4ba79a9c1697daf310f4e6bcbf26419baec3cbf72ef1e5d155be435a9 \ - --hash=sha256:f95fcfde0ee0363b7e264b0b35edeb5a1190da08427493450f1d08abd3b0328c -mypy-boto3-identitystore==1.28.0 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ - --hash=sha256:0247c095f95202abe98ca62c1da70a30e27122aba59bd2b37d9c1d02cab16ca1 \ - --hash=sha256:7f09952167f4038f18cfcbb5a9a06ed843af3231818b7a379279b66c523e9e0d -mypy-boto3-organizations==1.28.0 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ - --hash=sha256:0c2abe2bafd0a4fa5a2c6bd29038c2a38ef0532a2348bd5df1b3f5ff309ead62 \ - --hash=sha256:cd69c2eb3f8e3746fe599a4a23a55c6e1704eeec31d33ccce3501f17bc644a70 -mypy-boto3-s3==1.28.3.post2 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ - --hash=sha256:02dc6b9edf799afa160be1e3a9b38a383af6fcd80a97abd75d09410b26621e70 \ - --hash=sha256:c23b7802b80a0388a146d7e8025552f21d44e349026a18fb751d9c698ed714b1 -mypy-boto3-scheduler==1.28.0 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ - --hash=sha256:4ac16803cc3fd72e5cb874bb888570cd1cb554df303a8cb02a8d022df08fcb25 \ - --hash=sha256:e30e0db89be9dbc92790bb23b6f503f087da7f8c5ea2a111d620d614ef265221 -mypy-boto3-sso-admin==1.28.0 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ - --hash=sha256:a61f53679501bdba5ceb4cdc3b35ce940ed01064049dbcebf1fafcb74a57cab7 \ - --hash=sha256:b1a2f0ca25dc0eaf8839b7b65ff7a8e0a566daa027c6bbfb9e943f6f78a8e50f -pydantic[email]==1.10.13 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ - --hash=sha256:1740068fd8e2ef6eb27a20e5651df000978edce6da6803c2bef0bc74540f9548 \ - --hash=sha256:210ce042e8f6f7c01168b2d84d4c9eb2b009fe7bf572c2266e235edf14bacd80 \ - --hash=sha256:32c8b48dcd3b2ac4e78b0ba4af3a2c2eb6048cb75202f0ea7b34feb740efc340 \ - --hash=sha256:3ecea2b9d80e5333303eeb77e180b90e95eea8f765d08c3d278cd56b00345d01 \ - --hash=sha256:4b03e42ec20286f052490423682016fd80fda830d8e4119f8ab13ec7464c0132 \ - --hash=sha256:4c5370a7edaac06daee3af1c8b1192e305bc102abcbf2a92374b5bc793818599 \ - --hash=sha256:56e3ff861c3b9c6857579de282ce8baabf443f42ffba355bf070770ed63e11e1 \ - --hash=sha256:5a1f9f747851338933942db7af7b6ee8268568ef2ed86c4185c6ef4402e80ba8 \ - --hash=sha256:5e08865bc6464df8c7d61439ef4439829e3ab62ab1669cddea8dd00cd74b9ffe \ - --hash=sha256:61d9dce220447fb74f45e73d7ff3b530e25db30192ad8d425166d43c5deb6df0 \ - --hash=sha256:654db58ae399fe6434e55325a2c3e959836bd17a6f6a0b6ca8107ea0571d2e17 \ - --hash=sha256:678bcf5591b63cc917100dc50ab6caebe597ac67e8c9ccb75e698f66038ea953 \ - --hash=sha256:6cf25c1a65c27923a17b3da28a0bdb99f62ee04230c931d83e888012851f4e7f \ - --hash=sha256:75ac15385a3534d887a99c713aa3da88a30fbd6204a5cd0dc4dab3d770b9bd2f \ - --hash=sha256:75b297827b59bc229cac1a23a2f7a4ac0031068e5be0ce385be1462e7e17a35d \ - --hash=sha256:7d6f6e7305244bddb4414ba7094ce910560c907bdfa3501e9db1a7fd7eaea127 \ - --hash=sha256:84bafe2e60b5e78bc64a2941b4c071a4b7404c5c907f5f5a99b0139781e69ed8 \ - --hash=sha256:854223752ba81e3abf663d685f105c64150873cc6f5d0c01d3e3220bcff7d36f \ - --hash=sha256:8ae5dd6b721459bfa30805f4c25880e0dd78fc5b5879f9f7a692196ddcb5a580 \ - --hash=sha256:8ef467901d7a41fa0ca6db9ae3ec0021e3f657ce2c208e98cd511f3161c762c6 \ - --hash=sha256:968ac42970f57b8344ee08837b62f6ee6f53c33f603547a55571c954a4225691 \ - --hash=sha256:97cce3ae7341f7620a0ba5ef6cf043975cd9d2b81f3aa5f4ea37928269bc1b87 \ - --hash=sha256:9849f031cf8a2f0a928fe885e5a04b08006d6d41876b8bbd2fc68a18f9f2e3fd \ - --hash=sha256:9f00790179497767aae6bcdc36355792c79e7bbb20b145ff449700eb076c5f96 \ - --hash=sha256:b87326822e71bd5f313e7d3bfdc77ac3247035ac10b0c0618bd99dcf95b1e687 \ - --hash=sha256:b97c1fac8c49be29486df85968682b0afa77e1b809aff74b83081cc115e52f33 \ - --hash=sha256:bc0898c12f8e9c97f6cd44c0ed70d55749eaf783716896960b4ecce2edfd2d69 \ - --hash=sha256:c553f6a156deb868ba38a23cf0df886c63492e9257f60a79c0fd8e7173537653 \ - --hash=sha256:c636925f38b8db208e09d344c7aa4f29a86bb9947495dd6b6d376ad10334fb78 \ - --hash=sha256:c958d053453a1c4b1c2062b05cd42d9d5c8eb67537b8d5a7e3c3032943ecd261 \ - --hash=sha256:d3a3c792a58e1622667a2837512099eac62490cdfd63bd407993aaf200a4cf1f \ - --hash=sha256:e31647d85a2013d926ce60b84f9dd5300d44535a9941fe825dc349ae1f760df9 \ - --hash=sha256:e70ca129d2053fb8b728ee7d1af8e553a928d7e301a311094b8a0501adc8763d \ - --hash=sha256:efff03cc7a4f29d9009d1c96ceb1e7a70a65cfe86e89d34e4a5f2ab1e5693737 \ - --hash=sha256:f59ef915cac80275245824e9d771ee939133be38215555e9dc90c6cb148aaeb5 \ - --hash=sha256:f8e81fc5fb17dae698f52bdd1c4f18b6ca674d7068242b2aff075f588301bbb0 -python-dateutil==2.8.2 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ - --hash=sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86 \ - --hash=sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9 +mypy-boto3-dynamodb==1.34.148 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ + --hash=sha256:c85489b92cbbbe4f6997070372022df914d4cb8eb707fdc73aa18ce6ba25c578 \ + --hash=sha256:f1a7aabff5c6e926b9b272df87251c9d6dfceb4c1fb159fb5a2df52062cd7e87 +mypy-boto3-events==1.34.151 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ + --hash=sha256:745dc718c618bec043180c8a78e52401b3536999848e1b0c20e9c7669eb2a3f3 \ + --hash=sha256:c9b4d4d92b1ae3b2c4c48bf99bbb8b4ed472866715b6728f94a0f446c6f1fb9a +mypy-boto3-identitystore==1.34.0 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ + --hash=sha256:1b8749a89ca1440608169eaa45afe2ec9aa0dbd02a754ee5cc62ce064426c23f \ + --hash=sha256:39d26c323ada4dee2a8696e504ebb0afefb841f88c5ed69a3070c010e4c1208e +mypy-boto3-organizations==1.34.139 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ + --hash=sha256:06fd26f02e8e918852ab681558215e607873749966759b5f58df1ff2e9a45392 \ + --hash=sha256:6b42f6ee20ef44ecec1b9ccd66c122dff43f43e60815e4c810a23e00fc08ead7 +mypy-boto3-s3==1.34.158 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ + --hash=sha256:a875eb0ee91ba5ac6967291887f6e924eb9dc157966e8181da20533086218166 \ + --hash=sha256:c01f1b2304ba7718c8561aaa2b6dc70fe438c91964256aa6ddc508d1cc553c66 +mypy-boto3-scheduler==1.34.0 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ + --hash=sha256:88ef3800caa0c838882a904a850b40cb7372adca83e3530397ff70cba977d62d \ + --hash=sha256:fa09d08d63eda7b29523fa886366971c3f6233b459203974270468b2d7e18f37 +mypy-boto3-sso-admin==1.34.0 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ + --hash=sha256:6de7cf9327a10b800ac4ca2dbd1cf27d9a64aa5a647e923638a552c382059e3a \ + --hash=sha256:9f871c83493be78a46a9df6bab209dc9bf0eb739822aca534901dd5ab2f91d61 +pydantic[email]==1.10.17 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ + --hash=sha256:098ad8de840c92ea586bf8efd9e2e90c6339d33ab5c1cfbb85be66e4ecf8213f \ + --hash=sha256:0e2495309b1266e81d259a570dd199916ff34f7f51f1b549a0d37a6d9b17b4dc \ + --hash=sha256:0fa51175313cc30097660b10eec8ca55ed08bfa07acbfe02f7a42f6c242e9a4b \ + --hash=sha256:11289fa895bcbc8f18704efa1d8020bb9a86314da435348f59745473eb042e6b \ + --hash=sha256:2a72d2a5ff86a3075ed81ca031eac86923d44bc5d42e719d585a8eb547bf0c9b \ + --hash=sha256:371dcf1831f87c9e217e2b6a0c66842879a14873114ebb9d0861ab22e3b5bb1e \ + --hash=sha256:409b2b36d7d7d19cd8310b97a4ce6b1755ef8bd45b9a2ec5ec2b124db0a0d8f3 \ + --hash=sha256:4866a1579c0c3ca2c40575398a24d805d4db6cb353ee74df75ddeee3c657f9a7 \ + --hash=sha256:48db882e48575ce4b39659558b2f9f37c25b8d348e37a2b4e32971dd5a7d6227 \ + --hash=sha256:525bbef620dac93c430d5d6bdbc91bdb5521698d434adf4434a7ef6ffd5c4b7f \ + --hash=sha256:543da3c6914795b37785703ffc74ba4d660418620cc273490d42c53949eeeca6 \ + --hash=sha256:62d96b8799ae3d782df7ec9615cb59fc32c32e1ed6afa1b231b0595f6516e8ab \ + --hash=sha256:6654028d1144df451e1da69a670083c27117d493f16cf83da81e1e50edce72ad \ + --hash=sha256:7017971ffa7fd7808146880aa41b266e06c1e6e12261768a28b8b41ba55c8076 \ + --hash=sha256:7623b59876f49e61c2e283551cc3647616d2fbdc0b4d36d3d638aae8547ea681 \ + --hash=sha256:7e17c0ee7192e54a10943f245dc79e36d9fe282418ea05b886e1c666063a7b54 \ + --hash=sha256:820ae12a390c9cbb26bb44913c87fa2ff431a029a785642c1ff11fed0a095fcb \ + --hash=sha256:94833612d6fd18b57c359a127cbfd932d9150c1b72fea7c86ab58c2a77edd7c7 \ + --hash=sha256:95ef534e3c22e5abbdbdd6f66b6ea9dac3ca3e34c5c632894f8625d13d084cbe \ + --hash=sha256:9c803a5113cfab7bbb912f75faa4fc1e4acff43e452c82560349fff64f852e1b \ + --hash=sha256:9e53fb834aae96e7b0dadd6e92c66e7dd9cdf08965340ed04c16813102a47fab \ + --hash=sha256:ab2f976336808fd5d539fdc26eb51f9aafc1f4b638e212ef6b6f05e753c8011d \ + --hash=sha256:ad1e33dc6b9787a6f0f3fd132859aa75626528b49cc1f9e429cdacb2608ad5f0 \ + --hash=sha256:ae5184e99a060a5c80010a2d53c99aee76a3b0ad683d493e5f0620b5d86eeb75 \ + --hash=sha256:aeb4e741782e236ee7dc1fb11ad94dc56aabaf02d21df0e79e0c21fe07c95741 \ + --hash=sha256:b4ad32aed3bf5eea5ca5decc3d1bbc3d0ec5d4fbcd72a03cdad849458decbc63 \ + --hash=sha256:b8ad363330557beac73159acfbeed220d5f1bfcd6b930302a987a375e02f74fd \ + --hash=sha256:bfbb18b616abc4df70591b8c1ff1b3eabd234ddcddb86b7cac82657ab9017e33 \ + --hash=sha256:c1e51d1af306641b7d1574d6d3307eaa10a4991542ca324f0feb134fee259815 \ + --hash=sha256:c31d281c7485223caf6474fc2b7cf21456289dbaa31401844069b77160cab9c7 \ + --hash=sha256:c7e8988bb16988890c985bd2093df9dd731bfb9d5e0860db054c23034fab8f7a \ + --hash=sha256:c87cedb4680d1614f1d59d13fea353faf3afd41ba5c906a266f3f2e8c245d655 \ + --hash=sha256:cafb9c938f61d1b182dfc7d44a7021326547b7b9cf695db5b68ec7b590214773 \ + --hash=sha256:d2f89a719411cb234105735a520b7c077158a81e0fe1cb05a79c01fc5eb59d3c \ + --hash=sha256:d4b40c9e13a0b61583e5599e7950490c700297b4a375b55b2b592774332798b7 \ + --hash=sha256:d4ecb515fa7cb0e46e163ecd9d52f9147ba57bc3633dca0e586cdb7a232db9e3 \ + --hash=sha256:d8c209af63ccd7b22fba94b9024e8b7fd07feffee0001efae50dd99316b27768 \ + --hash=sha256:db3b48d9283d80a314f7a682f7acae8422386de659fffaba454b77a083c3937d \ + --hash=sha256:e41b5b973e5c64f674b3b4720286ded184dcc26a691dd55f34391c62c6934688 \ + --hash=sha256:e840e6b2026920fc3f250ea8ebfdedf6ea7a25b77bf04c6576178e681942ae0f \ + --hash=sha256:ebb249096d873593e014535ab07145498957091aa6ae92759a32d40cb9998e2e \ + --hash=sha256:f434160fb14b353caf634149baaf847206406471ba70e64657c1e8330277a991 \ + --hash=sha256:fa43f362b46741df8f201bf3e7dff3569fa92069bcc7b4a740dea3602e27ab7a +python-dateutil==2.9.0.post0 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ + --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \ + --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 six==1.16.0 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ --hash=sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926 \ --hash=sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254 -types-awscrt==0.16.25 ; python_full_version >= "3.10.10" and python_version < "4.0" \ - --hash=sha256:0cb4e47fc5b8921c4d07b74e105cb5c94ac287f9502011ed26645a35ed79fe2d \ - --hash=sha256:b65366f075b6facb0187da9e017e6f3f5eb0d99e5607925955a6970c17b40fa0 -types-s3transfer==0.6.1 ; python_full_version >= "3.10.10" and python_version < "4.0" \ - --hash=sha256:6d1ac1dedac750d570428362acdf60fdd4f277b0788855c3894d3226756b2bfb \ - --hash=sha256:75ac1d7143d58c1e6af467cfd4a96c67ee058a3adf7c249d9309999e1f5f41e4 -typing-extensions==4.7.1 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ - --hash=sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36 \ - --hash=sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2 +types-awscrt==0.21.2 ; python_full_version >= "3.10.10" and python_version < "4.0" \ + --hash=sha256:0839fe12f0f914d8f7d63ed777c728cb4eccc2d5d79a26e377d12b0604e7bf0e \ + --hash=sha256:84a9f4f422ec525c314fdf54c23a1e73edfbcec968560943ca2d41cfae623b38 +types-s3transfer==0.10.1 ; python_full_version >= "3.10.10" and python_version < "4.0" \ + --hash=sha256:02154cce46528287ad76ad1a0153840e0492239a0887e8833466eccf84b98da0 \ + --hash=sha256:49a7c81fa609ac1532f8de3756e64b58afcecad8767933310228002ec7adff74 +typing-extensions==4.12.2 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ + --hash=sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d \ + --hash=sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8 diff --git a/src/entities/aws.py b/src/entities/aws.py index 454c29d..f83335e 100644 --- a/src/entities/aws.py +++ b/src/entities/aws.py @@ -12,3 +12,17 @@ class PermissionSet(BaseModel): name: str arn: str description: Optional[str] + + +class SSOGroup(BaseModel): + name: str + id: str + description: Optional[str] + identity_store_id: str + + +class GroupMembership(BaseModel): + user_principal_id: str + group_id: str + identity_store_id: str + membership_id: str diff --git a/src/errors.py b/src/errors.py index b06b562..020edf5 100644 --- a/src/errors.py +++ b/src/errors.py @@ -1,3 +1,12 @@ +import functools + +from aws_lambda_powertools import Logger +from slack_bolt import BoltContext +from slack_sdk import WebClient + +import config + + class ConfigurationError(Exception): ... @@ -8,3 +17,32 @@ class AccountAssignmentError(ConfigurationError): class NotFound(ConfigurationError): ... + + +logger = config.get_logger(service="errors") +cfg = config.get_config() + + +def error_handler(client: WebClient, e: Exception, logger: Logger, context: BoltContext, cfg: config.Config) -> None: + logger.exception(e) + if isinstance(e, ConfigurationError): + text = f"<@{context['user_id']}> Your request for AWS permissions failed with error: {e}. Check logs for more details." + else: + text = f"<@{context['user_id']}> Your request for AWS permissions failed with error. Check access-requester logs for more details." + + client.chat_postMessage(text=text, channel=cfg.slack_channel_id) + + +def handle_errors(fn): # noqa: ANN001, ANN201 + # Default slack error handler (app.error) does not handle all exceptions. Or at least I did not find how to do it. + # So I created this error handler. + @functools.wraps(fn) + def wrapper(*args, **kwargs): # noqa: ANN002, ANN003, ANN202 + try: + return fn(*args, **kwargs) + except Exception as e: + client: WebClient = kwargs["client"] + context: BoltContext = kwargs["context"] + error_handler(client=client, e=e, logger=logger, context=context, cfg=cfg) + + return wrapper diff --git a/src/events.py b/src/events.py index a31fe3e..c3240e5 100644 --- a/src/events.py +++ b/src/events.py @@ -16,6 +16,24 @@ class RevokeEvent(BaseModel): permission_duration: timedelta +class GroupRevokeEvent(BaseModel): + schedule_name: str + approver: entities.slack.User + requester: entities.slack.User + group_assignment: sso.GroupAssignment + permission_duration: timedelta + + +class ScheduledGroupRevokeEvent(BaseModel): + action: Literal["event_bridge_group_revoke"] + revoke_event: GroupRevokeEvent + + @root_validator(pre=True) + def validate_payload(cls, values: dict) -> dict: # noqa: ANN101 + values["revoke_event"] = GroupRevokeEvent.parse_raw(values["revoke_event"]) + return values + + class ScheduledRevokeEvent(BaseModel): action: Literal["event_bridge_revoke"] revoke_event: RevokeEvent @@ -51,5 +69,10 @@ class ApproverNotificationEvent(BaseModel): class Event(BaseModel): __root__: ( - ScheduledRevokeEvent | DiscardButtonsEvent | CheckOnInconsistency | SSOElevatorScheduledRevocation | ApproverNotificationEvent + ScheduledRevokeEvent + | DiscardButtonsEvent + | CheckOnInconsistency + | SSOElevatorScheduledRevocation + | ApproverNotificationEvent + | ScheduledGroupRevokeEvent ) = Field(..., discriminator="action") diff --git a/src/group.py b/src/group.py new file mode 100644 index 0000000..2225e9a --- /dev/null +++ b/src/group.py @@ -0,0 +1,230 @@ +from datetime import timedelta + +import boto3 +from mypy_boto3_identitystore import IdentityStoreClient +from mypy_boto3_sso_admin import SSOAdminClient +from mypy_boto3_scheduler import EventBridgeSchedulerClient +from slack_bolt import Ack, BoltContext +from slack_sdk import WebClient + +from slack_sdk.web.slack_response import SlackResponse + +import access_control +import config +import entities +import schedule +import slack_helpers +import sso +from errors import handle_errors + +logger = config.get_logger(service="main") +cfg = config.get_config() + +session = boto3._get_default_session() +sso_client: SSOAdminClient = session.client("sso-admin") +identity_store_client: IdentityStoreClient = session.client("identitystore") +schedule_client: EventBridgeSchedulerClient = session.client("scheduler") +sso_instance = sso.describe_sso_instance(sso_client, cfg.sso_instance_arn) +identity_store_id = sso_instance.identity_store_id + + +@handle_errors +def handle_request_for_group_access_submittion( + body: dict, + ack: Ack, # noqa: ARG001 + client: WebClient, + context: BoltContext, # noqa: ARG001 +) -> SlackResponse | None: + logger.info("Handling request for access submittion") + request = slack_helpers.RequestForGroupAccessView.parse(body) + logger.info("View submitted", extra={"view": request}) + requester = slack_helpers.get_user(client, id=request.requester_slack_id) + + group = sso.describe_group(identity_store_id, request.group_id, identity_store_client) + + decision = access_control.make_decision_on_access_request( + cfg.group_statements, + requester_email=requester.email, + group_id=request.group_id, + ) + + show_buttons = bool(decision.approvers) + slack_response = client.chat_postMessage( + blocks=slack_helpers.build_approval_request_message_blocks( + requester_slack_id=request.requester_slack_id, + group=group, + reason=request.reason, + permission_duration=request.permission_duration, + show_buttons=show_buttons, + color_coding_emoji=cfg.waiting_result_emoji, + ), + channel=cfg.slack_channel_id, + text=f"Request for access to {group.name} group from {requester.real_name}", + ) + + if show_buttons: + ts = slack_response["ts"] + if ts is not None: + schedule.schedule_discard_buttons_event( + schedule_client=schedule_client, # type: ignore # noqa: PGH003 + time_stamp=ts, + channel_id=cfg.slack_channel_id, + ) + schedule.schedule_approver_notification_event( + schedule_client=schedule_client, # type: ignore # noqa: PGH003 + message_ts=ts, + channel_id=cfg.slack_channel_id, + time_to_wait=timedelta( + minutes=cfg.approver_renotification_initial_wait_time, + ), + ) + + match decision.reason: + case access_control.DecisionReason.ApprovalNotRequired: + text = "Approval for this Group is not required. Request will be approved automatically." + color_coding_emoji = cfg.good_result_emoji + case access_control.DecisionReason.SelfApproval: + text = "Self approval is allowed and requester is an approver. Request will be approved automatically." + color_coding_emoji = cfg.good_result_emoji + case access_control.DecisionReason.RequiresApproval: + approvers = [slack_helpers.get_user_by_email(client, email) for email in decision.approvers] + mention_approvers = " ".join(f"<@{approver.id}>" for approver in approvers) + text = f"{mention_approvers} there is a request waiting for the approval." + color_coding_emoji = cfg.waiting_result_emoji + case access_control.DecisionReason.NoApprovers: + text = "Nobody can approve this request." + color_coding_emoji = cfg.bad_result_emoji + case access_control.DecisionReason.NoStatements: + text = "There are no statements for this Group." + color_coding_emoji = cfg.bad_result_emoji + + client.chat_postMessage(text=text, thread_ts=slack_response["ts"], channel=cfg.slack_channel_id) + + blocks = slack_helpers.HeaderSectionBlock.set_color_coding( + blocks=slack_response["message"]["blocks"], + color_coding_emoji=color_coding_emoji, + ) + client.chat_update( + channel=cfg.slack_channel_id, + ts=slack_response["ts"], + blocks=blocks, + text=text, + ) + + user_principal_id = sso.get_user_principal_id_by_email(identity_store_client, sso_instance.identity_store_id, requester.email) + + access_control.execute_decision_on_group_request( + group=group, + user_principal_id=user_principal_id, + permission_duration=request.permission_duration, + approver=requester, + requester=requester, + reason=request.reason, + decision=decision, + identity_store_id=identity_store_id, + ) + + if decision.grant: + client.chat_postMessage( + channel=cfg.slack_channel_id, + text=f"Permissions granted to <@{requester.id}>", + thread_ts=slack_response["ts"], + ) + + +cache_for_dublicate_requests = {} + + +@handle_errors +def handle_group_button_click(body: dict, client: WebClient, context: BoltContext) -> SlackResponse: # type: ignore # noqa: PGH003 ARG001 + logger.info("Handling button click") + payload = slack_helpers.ButtonGroupClickedPayload.parse_obj(body) + logger.info("Button click payload", extra={"payload": payload}) + approver = slack_helpers.get_user(client, id=payload.approver_slack_id) + requester = slack_helpers.get_user(client, id=payload.request.requester_slack_id) + + if ( + cache_for_dublicate_requests.get("requester_slack_id") == payload.request.requester_slack_id + and cache_for_dublicate_requests["group_id"] == payload.request.group_id + ): + return client.chat_postMessage( + channel=payload.channel_id, + text=f"<@{approver.id}> request is already in progress, please wait for the result.", + thread_ts=payload.thread_ts, + ) + cache_for_dublicate_requests["requester_slack_id"] = payload.request.requester_slack_id + cache_for_dublicate_requests["group_id"] = payload.request.group_id + + if payload.action == entities.ApproverAction.Discard: + blocks = slack_helpers.HeaderSectionBlock.set_color_coding( + blocks=payload.message["blocks"], + color_coding_emoji=cfg.bad_result_emoji, + ) + + blocks = slack_helpers.remove_blocks(blocks, block_ids=["buttons"]) + blocks.append(slack_helpers.button_click_info_block(payload.action, approver.id).to_dict()) + + text = f"Request was discarded by<@{approver.id}> " + client.chat_update( + channel=payload.channel_id, + ts=payload.thread_ts, + blocks=blocks, + text=text, + ) + + cache_for_dublicate_requests.clear() + return client.chat_postMessage( + channel=payload.channel_id, + text=text, + thread_ts=payload.thread_ts, + ) + + decision = access_control.make_decision_on_approve_request( + action=payload.action, + statements=cfg.group_statements, # type: ignore # noqa: PGH003 + group_id=payload.request.group_id, + approver_email=approver.email, + requester_email=requester.email, + ) + + logger.info("Decision on request was made", extra={"decision": decision}) + + if not decision.permit: + cache_for_dublicate_requests.clear() + return client.chat_postMessage( + channel=payload.channel_id, + text=f"<@{approver.id}> you can not approve this request", + thread_ts=payload.thread_ts, + ) + + text = f"Permissions granted to <@{requester.id}> by <@{approver.id}>." + blocks = slack_helpers.HeaderSectionBlock.set_color_coding( + blocks=payload.message["blocks"], + color_coding_emoji=cfg.good_result_emoji, + ) + + blocks = slack_helpers.remove_blocks(blocks, block_ids=["buttons"]) + blocks.append(slack_helpers.button_click_info_block(payload.action, approver.id).to_dict()) + client.chat_update( + channel=payload.channel_id, + ts=payload.thread_ts, + blocks=blocks, + text=text, + ) + + access_control.execute_decision_on_group_request( + decision=decision, + group=sso.describe_group(identity_store_id, payload.request.group_id, identity_store_client), + user_principal_id=sso.get_user_principal_id_by_email(identity_store_client, sso_instance.identity_store_id, requester.email), + permission_duration=payload.request.permission_duration, + approver=approver, + requester=requester, + reason=payload.request.reason, + identity_store_id=identity_store_id, + ) + cache_for_dublicate_requests.clear() + return client.chat_postMessage( + channel=payload.channel_id, + text=text, + thread_ts=payload.thread_ts, + ) diff --git a/src/main.py b/src/main.py index f9036e4..e1a45e7 100644 --- a/src/main.py +++ b/src/main.py @@ -1,8 +1,7 @@ -import functools +import group from datetime import timedelta import boto3 -from aws_lambda_powertools import Logger from slack_bolt import Ack, App, BoltContext from slack_bolt.adapter.aws_lambda import SlackRequestHandler from slack_sdk import WebClient @@ -11,11 +10,13 @@ import access_control import config import entities -import errors import organizations import schedule import slack_helpers import sso +from errors import handle_errors +from typing import Callable + logger = config.get_logger(service="main") @@ -23,6 +24,7 @@ schedule_client = session.client("scheduler") org_client = session.client("organizations") sso_client = session.client("sso-admin") +identity_store_client = session.client("identitystore") cfg = config.get_config() app = App( @@ -36,31 +38,6 @@ def lambda_handler(event: str, context): # noqa: ANN001, ANN201 return slack_handler.handle(event, context) -def error_handler(client: WebClient, e: Exception, logger: Logger, context: BoltContext) -> None: - logger.exception(e) - if isinstance(e, errors.ConfigurationError): - text = f"<@{context['user_id']}> Your request for AWS permissions failed with error: {e}. Check logs for more details." - client.chat_postMessage(text=text, channel=cfg.slack_channel_id) - else: - text = f"<@{context['user_id']}> Your request for AWS permissions failed with error. Check access-requester logs for more details." - client.chat_postMessage(text=text, channel=cfg.slack_channel_id) - - -def handle_errors(fn): # noqa: ANN001, ANN201 - # Default slack error handler (app.error) does not handle all exceptions. Or at least I did not find how to do it. - # So I created this error handler. - @functools.wraps(fn) - def wrapper(*args, **kwargs): # noqa: ANN002, ANN003, ANN202 - try: - return fn(*args, **kwargs) - except Exception as e: - client: WebClient = kwargs["client"] - context: BoltContext = kwargs["context"] - error_handler(client=client, e=e, logger=logger, context=context) - - return wrapper - - trigger_view_map = {} # To update the view, it is necessary to know the view_id. It is returned when the view is opened. # But shortcut 'request_for_access' handled by two functions. The first one opens the view and the second one updates it. @@ -68,17 +45,37 @@ def wrapper(*args, **kwargs): # noqa: ANN002, ANN003, ANN202 # and available in both functions, we can use it as a key. The value is the view_id. -def show_initial_form(client: WebClient, body: dict, ack: Ack) -> SlackResponse: - ack() - logger.info("Showing initial form") +def build_initial_form_handler( + view_class: slack_helpers.RequestForAccessView | slack_helpers.RequestForGroupAccessView, +) -> Callable[[WebClient, dict, Ack], SlackResponse]: + def show_initial_form_for_request( + client: WebClient, + body: dict, + ack: Ack, + ) -> SlackResponse: + ack() + logger.info(f"Showing initial form for {view_class.__name__}") + logger.debug("Request body", extra={"body": body}) + trigger_id = body["trigger_id"] + response = client.views_open(trigger_id=trigger_id, view=view_class.build()) + trigger_view_map[trigger_id] = response.data["view"]["id"] # type: ignore # noqa: PGH003 + return response + + return show_initial_form_for_request + + +def load_select_options_for_group_access_request(client: WebClient, body: dict) -> SlackResponse: + logger.info("Loading select options for view (groups)") logger.debug("Request body", extra={"body": body}) + sso_instance = sso.describe_sso_instance(sso_client, cfg.sso_instance_arn) + groups = sso.get_groups_from_config(sso_instance.identity_store_id, identity_store_client, cfg) trigger_id = body["trigger_id"] - response = client.views_open(trigger_id=trigger_id, view=slack_helpers.RequestForAccessView.build()) - trigger_view_map[trigger_id] = response.data["view"]["id"] # type: ignore # noqa: PGH003 - return response + + view = slack_helpers.RequestForGroupAccessView.update_with_groups(groups=groups) + return client.views_update(view_id=trigger_view_map[trigger_id], view=view) -def load_select_options(client: WebClient, body: dict) -> SlackResponse: +def load_select_options_for_account_access_request(client: WebClient, body: dict) -> SlackResponse: logger.info("Loading select options for view (accounts and permission sets)") logger.debug("Request body", extra={"body": body}) @@ -91,10 +88,14 @@ def load_select_options(client: WebClient, body: dict) -> SlackResponse: app.shortcut("request_for_access")( - show_initial_form, - load_select_options, + build_initial_form_handler(view_class=slack_helpers.RequestForAccessView), # type: ignore # noqa: PGH003 + load_select_options_for_account_access_request, ) +app.shortcut("request_for_group_membership")( + build_initial_form_handler(view_class=slack_helpers.RequestForGroupAccessView), # type: ignore # noqa: PGH003 + load_select_options_for_group_access_request, +) cache_for_dublicate_requests = {} @@ -102,7 +103,12 @@ def load_select_options(client: WebClient, body: dict) -> SlackResponse: @handle_errors def handle_button_click(body: dict, client: WebClient, context: BoltContext) -> SlackResponse: # noqa: ARG001 logger.info("Handling button click") - payload = slack_helpers.ButtonClickedPayload.parse_obj(body) + try: + payload = slack_helpers.ButtonClickedPayload.parse_obj(body) + except Exception as e: + logger.exception(e) + return group.handle_group_button_click(body, client, context) + logger.info("Button click payload", extra={"payload": payload}) approver = slack_helpers.get_user(client, id=payload.approver_slack_id) requester = slack_helpers.get_user(client, id=payload.request.requester_slack_id) @@ -318,6 +324,11 @@ def handle_request_for_access_submittion( lazy=[handle_request_for_access_submittion], ) +app.view(slack_helpers.RequestForGroupAccessView.CALLBACK_ID)( + ack=acknowledge_request, + lazy=[group.handle_request_for_group_access_submittion], +) + @app.action("duration_picker_action") def handle_duration_picker_action(ack): # noqa: ANN201, ANN001 diff --git a/src/requirements.txt b/src/requirements.txt index 47f0903..b9646b6 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -1,47 +1,47 @@ -attrs==23.1.0 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ - --hash=sha256:1f28b4522cdc2fb4256ac1a020c78acf9cba2c6b461ccd2c126f3aa8e8335d04 \ - --hash=sha256:6279836d581513a26f1bf235f9acd333bc9115683f14f7e8fae46c98fc50e015 -aws-lambda-powertools[parser]==2.20.0 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ - --hash=sha256:026d64e1fe8e9d6ebfe2e3d6ed0a4c5b7e7a5dfa1ebdb99af3b6cd5fbc411b72 \ - --hash=sha256:137a5e83ff6160e7b7c790d106bfa0bea78ba4147925b57c2797ece3719997f8 -black==24.3.0 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ - --hash=sha256:2818cf72dfd5d289e48f37ccfa08b460bf469e67fb7c4abb07edc2e9f16fb63f \ - --hash=sha256:41622020d7120e01d377f74249e677039d20e6344ff5851de8a10f11f513bf93 \ - --hash=sha256:4acf672def7eb1725f41f38bf6bf425c8237248bb0804faa3965c036f7672d11 \ - --hash=sha256:4be5bb28e090456adfc1255e03967fb67ca846a03be7aadf6249096100ee32d0 \ - --hash=sha256:4f1373a7808a8f135b774039f61d59e4be7eb56b2513d3d2f02a8b9365b8a8a9 \ - --hash=sha256:56f52cfbd3dabe2798d76dbdd299faa046a901041faf2cf33288bc4e6dae57b5 \ - --hash=sha256:65b76c275e4c1c5ce6e9870911384bff5ca31ab63d19c76811cb1fb162678213 \ - --hash=sha256:65c02e4ea2ae09d16314d30912a58ada9a5c4fdfedf9512d23326128ac08ac3d \ - --hash=sha256:6905238a754ceb7788a73f02b45637d820b2f5478b20fec82ea865e4f5d4d9f7 \ - --hash=sha256:79dcf34b33e38ed1b17434693763301d7ccbd1c5860674a8f871bd15139e7837 \ - --hash=sha256:7bb041dca0d784697af4646d3b62ba4a6b028276ae878e53f6b4f74ddd6db99f \ - --hash=sha256:7d5e026f8da0322b5662fa7a8e752b3fa2dac1c1cbc213c3d7ff9bdd0ab12395 \ - --hash=sha256:9f50ea1132e2189d8dff0115ab75b65590a3e97de1e143795adb4ce317934995 \ - --hash=sha256:a0c9c4a0771afc6919578cec71ce82a3e31e054904e7197deacbc9382671c41f \ - --hash=sha256:aadf7a02d947936ee418777e0247ea114f78aff0d0959461057cae8a04f20597 \ - --hash=sha256:b5991d523eee14756f3c8d5df5231550ae8993e2286b8014e2fdea7156ed0959 \ - --hash=sha256:bf21b7b230718a5f08bd32d5e4f1db7fc8788345c8aea1d155fc17852b3410f5 \ - --hash=sha256:c45f8dff244b3c431b36e3224b6be4a127c6aca780853574c00faf99258041eb \ - --hash=sha256:c7ed6668cbbfcd231fa0dc1b137d3e40c04c7f786e626b405c62bcd5db5857e4 \ - --hash=sha256:d7de8d330763c66663661a1ffd432274a2f92f07feeddd89ffd085b5744f85e7 \ - --hash=sha256:e19cb1c6365fd6dc38a6eae2dcb691d7d83935c10215aef8e6c38edee3f77abd \ - --hash=sha256:e2af80566f43c85f5797365077fb64a393861a3730bd110971ab7a0c94e873e7 -boto3-stubs[dynamodb,events,identitystore,organizations,s3,scheduler,sso-admin]==1.28.4 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ - --hash=sha256:7a68d2a456d995318a6237eb5c6039f63b6a9bf902f216ff2261f87015fbb1c3 \ - --hash=sha256:a5b1cb7cdfd83f34b573c9af4eda80a62638f2ac78fae9c972721d3837e1b918 -boto3==1.28.4 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ - --hash=sha256:1f4b9c23dfcad910b6f8e74aac9fe507c1e75fcdd832e25ed2ff1e6d7a99cddf \ - --hash=sha256:92c0631ab91b4c5aa0e18a90b4d12df361723c6df1ef7e346db71f2ad0803ab3 -botocore-stubs==1.31.4 ; python_full_version >= "3.10.10" and python_version < "4.0" \ - --hash=sha256:3fedfe390a3e8e9d6b287f9ef5bb5a706a1d0e4305881506f6889537894e9a90 \ - --hash=sha256:b52f570927621076304d3da6e77cef547e18b9d30f335630f39cc78d3452eeb0 -botocore==1.31.4 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ - --hash=sha256:1c14ac4521af707a7a407cee0e22695ce3e95c0f1a0c974e21cb25a3ce78a538 \ - --hash=sha256:f9738a23b03c55c2958ebdee65273afeda80deaeefebe595887fc3251e48293a -click==8.1.5 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ - --hash=sha256:4be4b1af8d665c6d942909916d31a213a106800c47d0eeba73d34da3cbc11367 \ - --hash=sha256:e576aa487d679441d7d30abb87e1b43d24fc53bffb8758443b1a9e1cee504548 +attrs==24.2.0 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ + --hash=sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346 \ + --hash=sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2 +aws-lambda-powertools[parser]==2.43.1 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ + --hash=sha256:48116250c1771c7b8d4977ad2d475271074d86964107ccfd3fc6775e51984d88 \ + --hash=sha256:5c371a0c0430cf7bca1696748cb0d85079aac2c51056cbee10e5435029b35ca4 +black==24.8.0 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ + --hash=sha256:09cdeb74d494ec023ded657f7092ba518e8cf78fa8386155e4a03fdcc44679e6 \ + --hash=sha256:1f13f7f386f86f8121d76599114bb8c17b69d962137fc70efe56137727c7047e \ + --hash=sha256:2500945420b6784c38b9ee885af039f5e7471ef284ab03fa35ecdde4688cd83f \ + --hash=sha256:2b59b250fdba5f9a9cd9d0ece6e6d993d91ce877d121d161e4698af3eb9c1018 \ + --hash=sha256:3c4285573d4897a7610054af5a890bde7c65cb466040c5f0c8b732812d7f0e5e \ + --hash=sha256:505289f17ceda596658ae81b61ebbe2d9b25aa78067035184ed0a9d855d18afd \ + --hash=sha256:62e8730977f0b77998029da7971fa896ceefa2c4c4933fcd593fa599ecbf97a4 \ + --hash=sha256:649f6d84ccbae73ab767e206772cc2d7a393a001070a4c814a546afd0d423aed \ + --hash=sha256:6e55d30d44bed36593c3163b9bc63bf58b3b30e4611e4d88a0c3c239930ed5b2 \ + --hash=sha256:707a1ca89221bc8a1a64fb5e15ef39cd755633daa672a9db7498d1c19de66a42 \ + --hash=sha256:72901b4913cbac8972ad911dc4098d5753704d1f3c56e44ae8dce99eecb0e3af \ + --hash=sha256:73bbf84ed136e45d451a260c6b73ed674652f90a2b3211d6a35e78054563a9bb \ + --hash=sha256:7c046c1d1eeb7aea9335da62472481d3bbf3fd986e093cffd35f4385c94ae368 \ + --hash=sha256:81c6742da39f33b08e791da38410f32e27d632260e599df7245cccee2064afeb \ + --hash=sha256:837fd281f1908d0076844bc2b801ad2d369c78c45cf800cad7b61686051041af \ + --hash=sha256:972085c618ee94f402da1af548a4f218c754ea7e5dc70acb168bfaca4c2542ed \ + --hash=sha256:9e84e33b37be070ba135176c123ae52a51f82306def9f7d063ee302ecab2cf47 \ + --hash=sha256:b19c9ad992c7883ad84c9b22aaa73562a16b819c1d8db7a1a1a49fb7ec13c7d2 \ + --hash=sha256:d6417535d99c37cee4091a2f24eb2b6d5ec42b144d50f1f2e436d9fe1916fe1a \ + --hash=sha256:eab4dd44ce80dea27dc69db40dab62d4ca96112f87996bca68cd75639aeb2e4c \ + --hash=sha256:f490dbd59680d809ca31efdae20e634f3fae27fba3ce0ba3208333b713bc3920 \ + --hash=sha256:fb6e2c0b86bbd43dee042e48059c9ad7830abd5c94b0bc518c0eeec57c3eddc1 +boto3-stubs[dynamodb,events,identitystore,organizations,s3,scheduler,sso-admin]==1.34.160 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ + --hash=sha256:7e499b74d53e8eb456e539b3c82ccb6b88038579e3434fb131d51a8abe11dc83 \ + --hash=sha256:c6b1dfeb3cae673eed596f01409339e2e0b955a5241a16cee69f29303d9b37de +boto3==1.34.160 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ + --hash=sha256:79450f92188a8b992b3d0b802028acadf448bc6fdde877c3262c9f94d74d1c7d \ + --hash=sha256:bf3153bf5d66be2bb2112edc94eb143c0cba3fb502c5591437bd1c54f57eb559 +botocore-stubs==1.34.160 ; python_full_version >= "3.10.10" and python_version < "4.0" \ + --hash=sha256:900953f3f926d205505776535fd131047ef89519734f1e5365d03ecbaec53cd9 \ + --hash=sha256:b16122567dbf0860a76960ea4b94a396f16ba1a6afb9577dcc11dcd55047c42b +botocore==1.34.160 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ + --hash=sha256:39bcf31318a062a8a9260bf7044131694ed18f019568d2eba0a22164fdca49bd \ + --hash=sha256:a5fd531c640fb2dc8b83f264efbb87a6e33b9c9f66ebbb1c61b42908f2786cac +click==8.1.7 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ + --hash=sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28 \ + --hash=sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de colorama==0.4.6 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" and (sys_platform == "win32" or platform_system == "Windows") \ --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 @@ -51,15 +51,15 @@ croniter==1.4.1 ; python_full_version >= "3.10.10" and python_full_version < "4. dnspython==2.6.1 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ --hash=sha256:5ef3b9680161f6fa89daf8ad451b5f1a33b18ae8a1c6778cdf4b43f08c0a6e50 \ --hash=sha256:e8f0f9c23a7b7cb99ded64e6c3a6f3e701d78f50c55e002b839dea7225cff7cc -email-validator==2.0.0.post2 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ - --hash=sha256:1ff6e86044200c56ae23595695c54e9614f4a9551e0e393614f764860b3d7900 \ - --hash=sha256:2466ba57cda361fb7309fd3d5a225723c788ca4bbad32a0ebd5373b99730285c -exceptiongroup==1.1.2 ; python_full_version >= "3.10.10" and python_version < "3.11" \ - --hash=sha256:12c3e887d6485d16943a309616de20ae5582633e0a2eda17f4e10fd61c1e8af5 \ - --hash=sha256:e346e69d186172ca7cf029c8c1d16235aa0e04035e5750b4b95039e65204328f -hypothesis[ghostwriter]==6.81.2 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ - --hash=sha256:4dafc0d3fc0e802ee5fa863e05e2cde54b9336e300d6c46f4e78b23b00cfa67a \ - --hash=sha256:e35165a73064370d30d476d7218f600d2bf861ff218192c9e994cb36aa190ae7 +email-validator==2.2.0 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ + --hash=sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631 \ + --hash=sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7 +exceptiongroup==1.2.2 ; python_full_version >= "3.10.10" and python_version < "3.11" \ + --hash=sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b \ + --hash=sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc +hypothesis[ghostwriter]==6.111.0 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ + --hash=sha256:04d0703621d9fdd61c079a4dda07babbe7ebf6d34eee6ad9484a2af0ee721801 \ + --hash=sha256:7a51f678da3719a04a3ef61cd241384dd93b49f35d7cce22833745c66ac1d507 idna==3.7 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ --hash=sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc \ --hash=sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0 @@ -69,122 +69,136 @@ iniconfig==2.0.0 ; python_full_version >= "3.10.10" and python_full_version < "4 jmespath==1.0.1 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ --hash=sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980 \ --hash=sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe -mypy-boto3-dynamodb==1.28.0 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ - --hash=sha256:622f3d14dc1835a17ca511672d2f8fd08c03c4930f2845d06d1632b9f0c92aaf \ - --hash=sha256:d12ed66edd7ded7089297b533d77e8b8ed8844da4e097cd912b61a08bfe4948b -mypy-boto3-events==1.28.0 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ - --hash=sha256:b7e362c4ba79a9c1697daf310f4e6bcbf26419baec3cbf72ef1e5d155be435a9 \ - --hash=sha256:f95fcfde0ee0363b7e264b0b35edeb5a1190da08427493450f1d08abd3b0328c -mypy-boto3-identitystore==1.28.0 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ - --hash=sha256:0247c095f95202abe98ca62c1da70a30e27122aba59bd2b37d9c1d02cab16ca1 \ - --hash=sha256:7f09952167f4038f18cfcbb5a9a06ed843af3231818b7a379279b66c523e9e0d -mypy-boto3-organizations==1.28.0 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ - --hash=sha256:0c2abe2bafd0a4fa5a2c6bd29038c2a38ef0532a2348bd5df1b3f5ff309ead62 \ - --hash=sha256:cd69c2eb3f8e3746fe599a4a23a55c6e1704eeec31d33ccce3501f17bc644a70 -mypy-boto3-s3==1.28.3.post2 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ - --hash=sha256:02dc6b9edf799afa160be1e3a9b38a383af6fcd80a97abd75d09410b26621e70 \ - --hash=sha256:c23b7802b80a0388a146d7e8025552f21d44e349026a18fb751d9c698ed714b1 -mypy-boto3-scheduler==1.28.0 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ - --hash=sha256:4ac16803cc3fd72e5cb874bb888570cd1cb554df303a8cb02a8d022df08fcb25 \ - --hash=sha256:e30e0db89be9dbc92790bb23b6f503f087da7f8c5ea2a111d620d614ef265221 -mypy-boto3-sso-admin==1.28.0 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ - --hash=sha256:a61f53679501bdba5ceb4cdc3b35ce940ed01064049dbcebf1fafcb74a57cab7 \ - --hash=sha256:b1a2f0ca25dc0eaf8839b7b65ff7a8e0a566daa027c6bbfb9e943f6f78a8e50f +mypy-boto3-dynamodb==1.34.148 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ + --hash=sha256:c85489b92cbbbe4f6997070372022df914d4cb8eb707fdc73aa18ce6ba25c578 \ + --hash=sha256:f1a7aabff5c6e926b9b272df87251c9d6dfceb4c1fb159fb5a2df52062cd7e87 +mypy-boto3-events==1.34.151 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ + --hash=sha256:745dc718c618bec043180c8a78e52401b3536999848e1b0c20e9c7669eb2a3f3 \ + --hash=sha256:c9b4d4d92b1ae3b2c4c48bf99bbb8b4ed472866715b6728f94a0f446c6f1fb9a +mypy-boto3-identitystore==1.34.0 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ + --hash=sha256:1b8749a89ca1440608169eaa45afe2ec9aa0dbd02a754ee5cc62ce064426c23f \ + --hash=sha256:39d26c323ada4dee2a8696e504ebb0afefb841f88c5ed69a3070c010e4c1208e +mypy-boto3-organizations==1.34.139 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ + --hash=sha256:06fd26f02e8e918852ab681558215e607873749966759b5f58df1ff2e9a45392 \ + --hash=sha256:6b42f6ee20ef44ecec1b9ccd66c122dff43f43e60815e4c810a23e00fc08ead7 +mypy-boto3-s3==1.34.158 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ + --hash=sha256:a875eb0ee91ba5ac6967291887f6e924eb9dc157966e8181da20533086218166 \ + --hash=sha256:c01f1b2304ba7718c8561aaa2b6dc70fe438c91964256aa6ddc508d1cc553c66 +mypy-boto3-scheduler==1.34.0 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ + --hash=sha256:88ef3800caa0c838882a904a850b40cb7372adca83e3530397ff70cba977d62d \ + --hash=sha256:fa09d08d63eda7b29523fa886366971c3f6233b459203974270468b2d7e18f37 +mypy-boto3-sso-admin==1.34.0 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ + --hash=sha256:6de7cf9327a10b800ac4ca2dbd1cf27d9a64aa5a647e923638a552c382059e3a \ + --hash=sha256:9f871c83493be78a46a9df6bab209dc9bf0eb739822aca534901dd5ab2f91d61 mypy-extensions==1.0.0 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ --hash=sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d \ --hash=sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782 -packaging==23.1 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ - --hash=sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61 \ - --hash=sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f -pathspec==0.11.1 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ - --hash=sha256:2798de800fa92780e33acca925945e9a19a133b715067cf165b8866c15a31687 \ - --hash=sha256:d8af70af76652554bd134c22b3e8a1cc46ed7d91edcdd721ef1a0c51a84a5293 -platformdirs==3.9.1 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ - --hash=sha256:1b42b450ad933e981d56e59f1b97495428c9bd60698baab9f3eb3d00d5822421 \ - --hash=sha256:ad8291ae0ae5072f66c16945166cb11c63394c7a3ad1b1bc9828ca3162da8c2f -pluggy==1.2.0 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ - --hash=sha256:c2fd55a7d7a3863cba1a013e4e2414658b1d07b6bc57b3919e0c63c9abb99849 \ - --hash=sha256:d12f0c4b579b15f5e054301bb226ee85eeeba08ffec228092f8defbaa3a4c4b3 -pydantic==1.10.13 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ - --hash=sha256:1740068fd8e2ef6eb27a20e5651df000978edce6da6803c2bef0bc74540f9548 \ - --hash=sha256:210ce042e8f6f7c01168b2d84d4c9eb2b009fe7bf572c2266e235edf14bacd80 \ - --hash=sha256:32c8b48dcd3b2ac4e78b0ba4af3a2c2eb6048cb75202f0ea7b34feb740efc340 \ - --hash=sha256:3ecea2b9d80e5333303eeb77e180b90e95eea8f765d08c3d278cd56b00345d01 \ - --hash=sha256:4b03e42ec20286f052490423682016fd80fda830d8e4119f8ab13ec7464c0132 \ - --hash=sha256:4c5370a7edaac06daee3af1c8b1192e305bc102abcbf2a92374b5bc793818599 \ - --hash=sha256:56e3ff861c3b9c6857579de282ce8baabf443f42ffba355bf070770ed63e11e1 \ - --hash=sha256:5a1f9f747851338933942db7af7b6ee8268568ef2ed86c4185c6ef4402e80ba8 \ - --hash=sha256:5e08865bc6464df8c7d61439ef4439829e3ab62ab1669cddea8dd00cd74b9ffe \ - --hash=sha256:61d9dce220447fb74f45e73d7ff3b530e25db30192ad8d425166d43c5deb6df0 \ - --hash=sha256:654db58ae399fe6434e55325a2c3e959836bd17a6f6a0b6ca8107ea0571d2e17 \ - --hash=sha256:678bcf5591b63cc917100dc50ab6caebe597ac67e8c9ccb75e698f66038ea953 \ - --hash=sha256:6cf25c1a65c27923a17b3da28a0bdb99f62ee04230c931d83e888012851f4e7f \ - --hash=sha256:75ac15385a3534d887a99c713aa3da88a30fbd6204a5cd0dc4dab3d770b9bd2f \ - --hash=sha256:75b297827b59bc229cac1a23a2f7a4ac0031068e5be0ce385be1462e7e17a35d \ - --hash=sha256:7d6f6e7305244bddb4414ba7094ce910560c907bdfa3501e9db1a7fd7eaea127 \ - --hash=sha256:84bafe2e60b5e78bc64a2941b4c071a4b7404c5c907f5f5a99b0139781e69ed8 \ - --hash=sha256:854223752ba81e3abf663d685f105c64150873cc6f5d0c01d3e3220bcff7d36f \ - --hash=sha256:8ae5dd6b721459bfa30805f4c25880e0dd78fc5b5879f9f7a692196ddcb5a580 \ - --hash=sha256:8ef467901d7a41fa0ca6db9ae3ec0021e3f657ce2c208e98cd511f3161c762c6 \ - --hash=sha256:968ac42970f57b8344ee08837b62f6ee6f53c33f603547a55571c954a4225691 \ - --hash=sha256:97cce3ae7341f7620a0ba5ef6cf043975cd9d2b81f3aa5f4ea37928269bc1b87 \ - --hash=sha256:9849f031cf8a2f0a928fe885e5a04b08006d6d41876b8bbd2fc68a18f9f2e3fd \ - --hash=sha256:9f00790179497767aae6bcdc36355792c79e7bbb20b145ff449700eb076c5f96 \ - --hash=sha256:b87326822e71bd5f313e7d3bfdc77ac3247035ac10b0c0618bd99dcf95b1e687 \ - --hash=sha256:b97c1fac8c49be29486df85968682b0afa77e1b809aff74b83081cc115e52f33 \ - --hash=sha256:bc0898c12f8e9c97f6cd44c0ed70d55749eaf783716896960b4ecce2edfd2d69 \ - --hash=sha256:c553f6a156deb868ba38a23cf0df886c63492e9257f60a79c0fd8e7173537653 \ - --hash=sha256:c636925f38b8db208e09d344c7aa4f29a86bb9947495dd6b6d376ad10334fb78 \ - --hash=sha256:c958d053453a1c4b1c2062b05cd42d9d5c8eb67537b8d5a7e3c3032943ecd261 \ - --hash=sha256:d3a3c792a58e1622667a2837512099eac62490cdfd63bd407993aaf200a4cf1f \ - --hash=sha256:e31647d85a2013d926ce60b84f9dd5300d44535a9941fe825dc349ae1f760df9 \ - --hash=sha256:e70ca129d2053fb8b728ee7d1af8e553a928d7e301a311094b8a0501adc8763d \ - --hash=sha256:efff03cc7a4f29d9009d1c96ceb1e7a70a65cfe86e89d34e4a5f2ab1e5693737 \ - --hash=sha256:f59ef915cac80275245824e9d771ee939133be38215555e9dc90c6cb148aaeb5 \ - --hash=sha256:f8e81fc5fb17dae698f52bdd1c4f18b6ca674d7068242b2aff075f588301bbb0 -pydantic[email]==1.10.13 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ - --hash=sha256:1740068fd8e2ef6eb27a20e5651df000978edce6da6803c2bef0bc74540f9548 \ - --hash=sha256:210ce042e8f6f7c01168b2d84d4c9eb2b009fe7bf572c2266e235edf14bacd80 \ - --hash=sha256:32c8b48dcd3b2ac4e78b0ba4af3a2c2eb6048cb75202f0ea7b34feb740efc340 \ - --hash=sha256:3ecea2b9d80e5333303eeb77e180b90e95eea8f765d08c3d278cd56b00345d01 \ - --hash=sha256:4b03e42ec20286f052490423682016fd80fda830d8e4119f8ab13ec7464c0132 \ - --hash=sha256:4c5370a7edaac06daee3af1c8b1192e305bc102abcbf2a92374b5bc793818599 \ - --hash=sha256:56e3ff861c3b9c6857579de282ce8baabf443f42ffba355bf070770ed63e11e1 \ - --hash=sha256:5a1f9f747851338933942db7af7b6ee8268568ef2ed86c4185c6ef4402e80ba8 \ - --hash=sha256:5e08865bc6464df8c7d61439ef4439829e3ab62ab1669cddea8dd00cd74b9ffe \ - --hash=sha256:61d9dce220447fb74f45e73d7ff3b530e25db30192ad8d425166d43c5deb6df0 \ - --hash=sha256:654db58ae399fe6434e55325a2c3e959836bd17a6f6a0b6ca8107ea0571d2e17 \ - --hash=sha256:678bcf5591b63cc917100dc50ab6caebe597ac67e8c9ccb75e698f66038ea953 \ - --hash=sha256:6cf25c1a65c27923a17b3da28a0bdb99f62ee04230c931d83e888012851f4e7f \ - --hash=sha256:75ac15385a3534d887a99c713aa3da88a30fbd6204a5cd0dc4dab3d770b9bd2f \ - --hash=sha256:75b297827b59bc229cac1a23a2f7a4ac0031068e5be0ce385be1462e7e17a35d \ - --hash=sha256:7d6f6e7305244bddb4414ba7094ce910560c907bdfa3501e9db1a7fd7eaea127 \ - --hash=sha256:84bafe2e60b5e78bc64a2941b4c071a4b7404c5c907f5f5a99b0139781e69ed8 \ - --hash=sha256:854223752ba81e3abf663d685f105c64150873cc6f5d0c01d3e3220bcff7d36f \ - --hash=sha256:8ae5dd6b721459bfa30805f4c25880e0dd78fc5b5879f9f7a692196ddcb5a580 \ - --hash=sha256:8ef467901d7a41fa0ca6db9ae3ec0021e3f657ce2c208e98cd511f3161c762c6 \ - --hash=sha256:968ac42970f57b8344ee08837b62f6ee6f53c33f603547a55571c954a4225691 \ - --hash=sha256:97cce3ae7341f7620a0ba5ef6cf043975cd9d2b81f3aa5f4ea37928269bc1b87 \ - --hash=sha256:9849f031cf8a2f0a928fe885e5a04b08006d6d41876b8bbd2fc68a18f9f2e3fd \ - --hash=sha256:9f00790179497767aae6bcdc36355792c79e7bbb20b145ff449700eb076c5f96 \ - --hash=sha256:b87326822e71bd5f313e7d3bfdc77ac3247035ac10b0c0618bd99dcf95b1e687 \ - --hash=sha256:b97c1fac8c49be29486df85968682b0afa77e1b809aff74b83081cc115e52f33 \ - --hash=sha256:bc0898c12f8e9c97f6cd44c0ed70d55749eaf783716896960b4ecce2edfd2d69 \ - --hash=sha256:c553f6a156deb868ba38a23cf0df886c63492e9257f60a79c0fd8e7173537653 \ - --hash=sha256:c636925f38b8db208e09d344c7aa4f29a86bb9947495dd6b6d376ad10334fb78 \ - --hash=sha256:c958d053453a1c4b1c2062b05cd42d9d5c8eb67537b8d5a7e3c3032943ecd261 \ - --hash=sha256:d3a3c792a58e1622667a2837512099eac62490cdfd63bd407993aaf200a4cf1f \ - --hash=sha256:e31647d85a2013d926ce60b84f9dd5300d44535a9941fe825dc349ae1f760df9 \ - --hash=sha256:e70ca129d2053fb8b728ee7d1af8e553a928d7e301a311094b8a0501adc8763d \ - --hash=sha256:efff03cc7a4f29d9009d1c96ceb1e7a70a65cfe86e89d34e4a5f2ab1e5693737 \ - --hash=sha256:f59ef915cac80275245824e9d771ee939133be38215555e9dc90c6cb148aaeb5 \ - --hash=sha256:f8e81fc5fb17dae698f52bdd1c4f18b6ca674d7068242b2aff075f588301bbb0 -pytest==7.4.0 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ - --hash=sha256:78bf16451a2eb8c7a2ea98e32dc119fd2aa758f1d5d66dbf0a59d69a3969df32 \ - --hash=sha256:b4bf8c45bd59934ed84001ad51e11b4ee40d40a1229d2c79f9c592b0a3f6bd8a -python-dateutil==2.8.2 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ - --hash=sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86 \ - --hash=sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9 +packaging==24.1 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ + --hash=sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002 \ + --hash=sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124 +pathspec==0.12.1 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ + --hash=sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08 \ + --hash=sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712 +platformdirs==4.2.2 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ + --hash=sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee \ + --hash=sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3 +pluggy==1.5.0 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ + --hash=sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1 \ + --hash=sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669 +pydantic==1.10.17 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ + --hash=sha256:098ad8de840c92ea586bf8efd9e2e90c6339d33ab5c1cfbb85be66e4ecf8213f \ + --hash=sha256:0e2495309b1266e81d259a570dd199916ff34f7f51f1b549a0d37a6d9b17b4dc \ + --hash=sha256:0fa51175313cc30097660b10eec8ca55ed08bfa07acbfe02f7a42f6c242e9a4b \ + --hash=sha256:11289fa895bcbc8f18704efa1d8020bb9a86314da435348f59745473eb042e6b \ + --hash=sha256:2a72d2a5ff86a3075ed81ca031eac86923d44bc5d42e719d585a8eb547bf0c9b \ + --hash=sha256:371dcf1831f87c9e217e2b6a0c66842879a14873114ebb9d0861ab22e3b5bb1e \ + --hash=sha256:409b2b36d7d7d19cd8310b97a4ce6b1755ef8bd45b9a2ec5ec2b124db0a0d8f3 \ + --hash=sha256:4866a1579c0c3ca2c40575398a24d805d4db6cb353ee74df75ddeee3c657f9a7 \ + --hash=sha256:48db882e48575ce4b39659558b2f9f37c25b8d348e37a2b4e32971dd5a7d6227 \ + --hash=sha256:525bbef620dac93c430d5d6bdbc91bdb5521698d434adf4434a7ef6ffd5c4b7f \ + --hash=sha256:543da3c6914795b37785703ffc74ba4d660418620cc273490d42c53949eeeca6 \ + --hash=sha256:62d96b8799ae3d782df7ec9615cb59fc32c32e1ed6afa1b231b0595f6516e8ab \ + --hash=sha256:6654028d1144df451e1da69a670083c27117d493f16cf83da81e1e50edce72ad \ + --hash=sha256:7017971ffa7fd7808146880aa41b266e06c1e6e12261768a28b8b41ba55c8076 \ + --hash=sha256:7623b59876f49e61c2e283551cc3647616d2fbdc0b4d36d3d638aae8547ea681 \ + --hash=sha256:7e17c0ee7192e54a10943f245dc79e36d9fe282418ea05b886e1c666063a7b54 \ + --hash=sha256:820ae12a390c9cbb26bb44913c87fa2ff431a029a785642c1ff11fed0a095fcb \ + --hash=sha256:94833612d6fd18b57c359a127cbfd932d9150c1b72fea7c86ab58c2a77edd7c7 \ + --hash=sha256:95ef534e3c22e5abbdbdd6f66b6ea9dac3ca3e34c5c632894f8625d13d084cbe \ + --hash=sha256:9c803a5113cfab7bbb912f75faa4fc1e4acff43e452c82560349fff64f852e1b \ + --hash=sha256:9e53fb834aae96e7b0dadd6e92c66e7dd9cdf08965340ed04c16813102a47fab \ + --hash=sha256:ab2f976336808fd5d539fdc26eb51f9aafc1f4b638e212ef6b6f05e753c8011d \ + --hash=sha256:ad1e33dc6b9787a6f0f3fd132859aa75626528b49cc1f9e429cdacb2608ad5f0 \ + --hash=sha256:ae5184e99a060a5c80010a2d53c99aee76a3b0ad683d493e5f0620b5d86eeb75 \ + --hash=sha256:aeb4e741782e236ee7dc1fb11ad94dc56aabaf02d21df0e79e0c21fe07c95741 \ + --hash=sha256:b4ad32aed3bf5eea5ca5decc3d1bbc3d0ec5d4fbcd72a03cdad849458decbc63 \ + --hash=sha256:b8ad363330557beac73159acfbeed220d5f1bfcd6b930302a987a375e02f74fd \ + --hash=sha256:bfbb18b616abc4df70591b8c1ff1b3eabd234ddcddb86b7cac82657ab9017e33 \ + --hash=sha256:c1e51d1af306641b7d1574d6d3307eaa10a4991542ca324f0feb134fee259815 \ + --hash=sha256:c31d281c7485223caf6474fc2b7cf21456289dbaa31401844069b77160cab9c7 \ + --hash=sha256:c7e8988bb16988890c985bd2093df9dd731bfb9d5e0860db054c23034fab8f7a \ + --hash=sha256:c87cedb4680d1614f1d59d13fea353faf3afd41ba5c906a266f3f2e8c245d655 \ + --hash=sha256:cafb9c938f61d1b182dfc7d44a7021326547b7b9cf695db5b68ec7b590214773 \ + --hash=sha256:d2f89a719411cb234105735a520b7c077158a81e0fe1cb05a79c01fc5eb59d3c \ + --hash=sha256:d4b40c9e13a0b61583e5599e7950490c700297b4a375b55b2b592774332798b7 \ + --hash=sha256:d4ecb515fa7cb0e46e163ecd9d52f9147ba57bc3633dca0e586cdb7a232db9e3 \ + --hash=sha256:d8c209af63ccd7b22fba94b9024e8b7fd07feffee0001efae50dd99316b27768 \ + --hash=sha256:db3b48d9283d80a314f7a682f7acae8422386de659fffaba454b77a083c3937d \ + --hash=sha256:e41b5b973e5c64f674b3b4720286ded184dcc26a691dd55f34391c62c6934688 \ + --hash=sha256:e840e6b2026920fc3f250ea8ebfdedf6ea7a25b77bf04c6576178e681942ae0f \ + --hash=sha256:ebb249096d873593e014535ab07145498957091aa6ae92759a32d40cb9998e2e \ + --hash=sha256:f434160fb14b353caf634149baaf847206406471ba70e64657c1e8330277a991 \ + --hash=sha256:fa43f362b46741df8f201bf3e7dff3569fa92069bcc7b4a740dea3602e27ab7a +pydantic[email]==1.10.17 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ + --hash=sha256:098ad8de840c92ea586bf8efd9e2e90c6339d33ab5c1cfbb85be66e4ecf8213f \ + --hash=sha256:0e2495309b1266e81d259a570dd199916ff34f7f51f1b549a0d37a6d9b17b4dc \ + --hash=sha256:0fa51175313cc30097660b10eec8ca55ed08bfa07acbfe02f7a42f6c242e9a4b \ + --hash=sha256:11289fa895bcbc8f18704efa1d8020bb9a86314da435348f59745473eb042e6b \ + --hash=sha256:2a72d2a5ff86a3075ed81ca031eac86923d44bc5d42e719d585a8eb547bf0c9b \ + --hash=sha256:371dcf1831f87c9e217e2b6a0c66842879a14873114ebb9d0861ab22e3b5bb1e \ + --hash=sha256:409b2b36d7d7d19cd8310b97a4ce6b1755ef8bd45b9a2ec5ec2b124db0a0d8f3 \ + --hash=sha256:4866a1579c0c3ca2c40575398a24d805d4db6cb353ee74df75ddeee3c657f9a7 \ + --hash=sha256:48db882e48575ce4b39659558b2f9f37c25b8d348e37a2b4e32971dd5a7d6227 \ + --hash=sha256:525bbef620dac93c430d5d6bdbc91bdb5521698d434adf4434a7ef6ffd5c4b7f \ + --hash=sha256:543da3c6914795b37785703ffc74ba4d660418620cc273490d42c53949eeeca6 \ + --hash=sha256:62d96b8799ae3d782df7ec9615cb59fc32c32e1ed6afa1b231b0595f6516e8ab \ + --hash=sha256:6654028d1144df451e1da69a670083c27117d493f16cf83da81e1e50edce72ad \ + --hash=sha256:7017971ffa7fd7808146880aa41b266e06c1e6e12261768a28b8b41ba55c8076 \ + --hash=sha256:7623b59876f49e61c2e283551cc3647616d2fbdc0b4d36d3d638aae8547ea681 \ + --hash=sha256:7e17c0ee7192e54a10943f245dc79e36d9fe282418ea05b886e1c666063a7b54 \ + --hash=sha256:820ae12a390c9cbb26bb44913c87fa2ff431a029a785642c1ff11fed0a095fcb \ + --hash=sha256:94833612d6fd18b57c359a127cbfd932d9150c1b72fea7c86ab58c2a77edd7c7 \ + --hash=sha256:95ef534e3c22e5abbdbdd6f66b6ea9dac3ca3e34c5c632894f8625d13d084cbe \ + --hash=sha256:9c803a5113cfab7bbb912f75faa4fc1e4acff43e452c82560349fff64f852e1b \ + --hash=sha256:9e53fb834aae96e7b0dadd6e92c66e7dd9cdf08965340ed04c16813102a47fab \ + --hash=sha256:ab2f976336808fd5d539fdc26eb51f9aafc1f4b638e212ef6b6f05e753c8011d \ + --hash=sha256:ad1e33dc6b9787a6f0f3fd132859aa75626528b49cc1f9e429cdacb2608ad5f0 \ + --hash=sha256:ae5184e99a060a5c80010a2d53c99aee76a3b0ad683d493e5f0620b5d86eeb75 \ + --hash=sha256:aeb4e741782e236ee7dc1fb11ad94dc56aabaf02d21df0e79e0c21fe07c95741 \ + --hash=sha256:b4ad32aed3bf5eea5ca5decc3d1bbc3d0ec5d4fbcd72a03cdad849458decbc63 \ + --hash=sha256:b8ad363330557beac73159acfbeed220d5f1bfcd6b930302a987a375e02f74fd \ + --hash=sha256:bfbb18b616abc4df70591b8c1ff1b3eabd234ddcddb86b7cac82657ab9017e33 \ + --hash=sha256:c1e51d1af306641b7d1574d6d3307eaa10a4991542ca324f0feb134fee259815 \ + --hash=sha256:c31d281c7485223caf6474fc2b7cf21456289dbaa31401844069b77160cab9c7 \ + --hash=sha256:c7e8988bb16988890c985bd2093df9dd731bfb9d5e0860db054c23034fab8f7a \ + --hash=sha256:c87cedb4680d1614f1d59d13fea353faf3afd41ba5c906a266f3f2e8c245d655 \ + --hash=sha256:cafb9c938f61d1b182dfc7d44a7021326547b7b9cf695db5b68ec7b590214773 \ + --hash=sha256:d2f89a719411cb234105735a520b7c077158a81e0fe1cb05a79c01fc5eb59d3c \ + --hash=sha256:d4b40c9e13a0b61583e5599e7950490c700297b4a375b55b2b592774332798b7 \ + --hash=sha256:d4ecb515fa7cb0e46e163ecd9d52f9147ba57bc3633dca0e586cdb7a232db9e3 \ + --hash=sha256:d8c209af63ccd7b22fba94b9024e8b7fd07feffee0001efae50dd99316b27768 \ + --hash=sha256:db3b48d9283d80a314f7a682f7acae8422386de659fffaba454b77a083c3937d \ + --hash=sha256:e41b5b973e5c64f674b3b4720286ded184dcc26a691dd55f34391c62c6934688 \ + --hash=sha256:e840e6b2026920fc3f250ea8ebfdedf6ea7a25b77bf04c6576178e681942ae0f \ + --hash=sha256:ebb249096d873593e014535ab07145498957091aa6ae92759a32d40cb9998e2e \ + --hash=sha256:f434160fb14b353caf634149baaf847206406471ba70e64657c1e8330277a991 \ + --hash=sha256:fa43f362b46741df8f201bf3e7dff3569fa92069bcc7b4a740dea3602e27ab7a +pytest==7.4.4 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ + --hash=sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280 \ + --hash=sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8 +python-dateutil==2.9.0.post0 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ + --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \ + --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 ruff==0.0.267 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ --hash=sha256:0afca3633c8e2b6c0a48ad0061180b641b3b404d68d7e6736aab301c8024c424 \ --hash=sha256:20c594eb56c19063ef5a57f89340e64c6550e169d6a29408a45130a8c3068adc \ @@ -203,33 +217,33 @@ ruff==0.0.267 ; python_full_version >= "3.10.10" and python_full_version < "4.0. --hash=sha256:d12ab329474c46b96d962e2bdb92e3ad2144981fe41b89c7770f370646c0101f \ --hash=sha256:db33deef2a5e1cf528ca51cc59dd764122a48a19a6c776283b223d147041153f \ --hash=sha256:f731d81cb939e757b0335b0090f18ca2e9ff8bcc8e6a1cf909245958949b6e11 -s3transfer==0.6.1 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ - --hash=sha256:3c0da2d074bf35d6870ef157158641178a4204a6e689e82546083e31e0311346 \ - --hash=sha256:640bb492711f4c0c0905e1f62b6aaeb771881935ad27884852411f8e9cacbca9 +s3transfer==0.10.2 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ + --hash=sha256:0711534e9356d3cc692fdde846b4a1e4b0cb6519971860796e6bc4c7aea00ef6 \ + --hash=sha256:eca1c20de70a39daee580aef4986996620f365c4e0fda6a86100231d62f1bf69 six==1.16.0 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ --hash=sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926 \ --hash=sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254 -slack-bolt==1.18.0 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ - --hash=sha256:43b121acf78440303ce5129e53be36bdfe5d926a193daef7daf2860688e65dd3 \ - --hash=sha256:63089a401ae3900c37698890249acd008a4651d06e86194edc7b72a00819bbac -slack-sdk==3.21.3 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ - --hash=sha256:20829bdc1a423ec93dac903470975ebf3bc76fd3fd91a4dadc0eeffc940ecb0c \ - --hash=sha256:de3c07b92479940b61cd68c566f49fbc9974c8f38f661d26244078f3903bb9cc +slack-bolt==1.19.1 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ + --hash=sha256:a3041d8f49eba22c3be4dd8f57602d6685d367c0e1cc7619260e1ce4a363d07f \ + --hash=sha256:b916829ece0ff7a2cae1502f1774fd100592cd8a81a39e4f04e3137a3f19522b +slack-sdk==3.31.0 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ + --hash=sha256:740d2f9c49cbfcbd46fca56b4be9d527934c225312aac18fd2c0fca0ef6bc935 \ + --hash=sha256:a120cc461e8ebb7d9175f171dbe0ded37a6878d9f7b96b28e4bad1227399047b sortedcontainers==2.4.0 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ --hash=sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88 \ --hash=sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0 tomli==2.0.1 ; python_full_version >= "3.10.10" and python_version < "3.11" \ --hash=sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc \ --hash=sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f -types-awscrt==0.16.25 ; python_full_version >= "3.10.10" and python_version < "4.0" \ - --hash=sha256:0cb4e47fc5b8921c4d07b74e105cb5c94ac287f9502011ed26645a35ed79fe2d \ - --hash=sha256:b65366f075b6facb0187da9e017e6f3f5eb0d99e5607925955a6970c17b40fa0 -types-s3transfer==0.6.1 ; python_full_version >= "3.10.10" and python_version < "4.0" \ - --hash=sha256:6d1ac1dedac750d570428362acdf60fdd4f277b0788855c3894d3226756b2bfb \ - --hash=sha256:75ac1d7143d58c1e6af467cfd4a96c67ee058a3adf7c249d9309999e1f5f41e4 -typing-extensions==4.7.1 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ - --hash=sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36 \ - --hash=sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2 -urllib3==1.26.19 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ - --hash=sha256:37a0344459b199fce0e80b0d3569837ec6b6937435c5244e7fd73fa6006830f3 \ - --hash=sha256:3e3d753a8618b86d7de333b4223005f68720bcd6a7d2bcb9fbd2229ec7c1e429 +types-awscrt==0.21.2 ; python_full_version >= "3.10.10" and python_version < "4.0" \ + --hash=sha256:0839fe12f0f914d8f7d63ed777c728cb4eccc2d5d79a26e377d12b0604e7bf0e \ + --hash=sha256:84a9f4f422ec525c314fdf54c23a1e73edfbcec968560943ca2d41cfae623b38 +types-s3transfer==0.10.1 ; python_full_version >= "3.10.10" and python_version < "4.0" \ + --hash=sha256:02154cce46528287ad76ad1a0153840e0492239a0887e8833466eccf84b98da0 \ + --hash=sha256:49a7c81fa609ac1532f8de3756e64b58afcecad8767933310228002ec7adff74 +typing-extensions==4.12.2 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ + --hash=sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d \ + --hash=sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8 +urllib3==2.2.2 ; python_full_version >= "3.10.10" and python_full_version < "4.0.0" \ + --hash=sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472 \ + --hash=sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168 diff --git a/src/revoker.py b/src/revoker.py index 1ae3599..655015a 100644 --- a/src/revoker.py +++ b/src/revoker.py @@ -22,7 +22,9 @@ CheckOnInconsistency, DiscardButtonsEvent, Event, + GroupRevokeEvent, RevokeEvent, + ScheduledGroupRevokeEvent, ScheduledRevokeEvent, SSOElevatorScheduledRevocation, ) @@ -59,6 +61,17 @@ def lambda_handler(event: dict, __) -> SlackResponse | None: # type: ignore # n identitystore_client=identitystore_client, ) + case ScheduledGroupRevokeEvent(): + logger.info("Handling GroupRevokeEvent", extra={"event": parsed_event}) + return handle_scheduled_group_assignment_deletion( + group_revoke_event=parsed_event.revoke_event, + sso_client=sso_client, + cfg=cfg, + scheduler_client=scheduler_client, + slack_client=slack_client, + identitystore_client=identitystore_client, + ) + case DiscardButtonsEvent(): logger.info("Handling DiscardButtonsEvent", extra={"event": parsed_event}) handle_discard_buttons_event(event=parsed_event, slack_client=slack_client, scheduler_client=scheduler_client) @@ -66,7 +79,14 @@ def lambda_handler(event: dict, __) -> SlackResponse | None: # type: ignore # n case CheckOnInconsistency(): logger.info("Handling CheckOnInconsistency event", extra={"event": parsed_event}) - + check_on_groups_inconsistency( + identity_store_client=identitystore_client, + sso_client=sso_client, + scheduler_client=scheduler_client, + events_client=events_client, + cfg=cfg, + slack_client=slack_client, + ) return handle_check_on_inconsistency( sso_client=sso_client, cfg=cfg, @@ -79,6 +99,13 @@ def lambda_handler(event: dict, __) -> SlackResponse | None: # type: ignore # n case SSOElevatorScheduledRevocation(): logger.info("Handling SSOElevatorScheduledRevocation event", extra={"event": parsed_event}) + handle_sso_elevator_group_scheduled_revocation( + identity_store_client=identitystore_client, + sso_client=sso_client, + scheduler_client=scheduler_client, + cfg=cfg, + slack_client=slack_client, + ) return handle_sso_elevator_scheduled_revocation( sso_client=sso_client, cfg=cfg, @@ -129,6 +156,8 @@ def handle_account_assignment_deletion( # noqa: PLR0913 approver_email="NA", operation_type="revoke", permission_duration="NA", + sso_user_principal_id=account_assignment.user_principal_id, + audit_entry_type="account", ), ) @@ -155,7 +184,9 @@ def slack_notify_user_on_revoke( # noqa: PLR0913 slack_client: slack_sdk.WebClient, ) -> SlackResponse: mention = slack_helpers.create_slack_mention_by_principal_id( - account_assignment=account_assignment, + sso_user_id=account_assignment.principal_id + if isinstance(account_assignment, sso.AccountAssignment) + else account_assignment.user_principal_id, sso_client=sso_client, cfg=cfg, identitystore_client=identitystore_client, @@ -167,6 +198,26 @@ def slack_notify_user_on_revoke( # noqa: PLR0913 ) +def slack_notify_user_on_group_access_revoke( # noqa: PLR0913 + cfg: config.Config, + group_assignment: sso.GroupAssignment, + sso_client: SSOAdminClient, + identitystore_client: IdentityStoreClient, + slack_client: slack_sdk.WebClient, +) -> SlackResponse: + mention = slack_helpers.create_slack_mention_by_principal_id( + sso_user_id=group_assignment.user_principal_id, + sso_client=sso_client, + cfg=cfg, + identitystore_client=identitystore_client, + slack_client=slack_client, + ) + return slack_client.chat_postMessage( + channel=cfg.slack_channel_id, + text=f"User {mention} has been removed from the group {group_assignment.group_name}.", + ) + + def handle_scheduled_account_assignment_deletion( # noqa: PLR0913 revoke_event: RevokeEvent, sso_client: SSOAdminClient, @@ -201,6 +252,8 @@ def handle_scheduled_account_assignment_deletion( # noqa: PLR0913 approver_email=revoke_event.approver.email, operation_type="revoke", permission_duration=revoke_event.permission_duration, + sso_user_principal_id=user_account_assignment.user_principal_id, + audit_entry_type="account", ), ) schedule.delete_schedule(scheduler_client, revoke_event.schedule_name) @@ -218,6 +271,43 @@ def handle_scheduled_account_assignment_deletion( # noqa: PLR0913 ) +def handle_scheduled_group_assignment_deletion( # noqa: PLR0913 + group_revoke_event: GroupRevokeEvent, + sso_client: SSOAdminClient, + cfg: config.Config, + scheduler_client: EventBridgeSchedulerClient, + slack_client: slack_sdk.WebClient, + identitystore_client: IdentityStoreClient, +) -> SlackResponse | None: + logger.info("Handling scheduled group access revokation", extra={"revoke_event": group_revoke_event}) + group_assignment = group_revoke_event.group_assignment + sso.remove_user_from_group(group_assignment.identity_store_id, group_assignment.membership_id, identitystore_client) + s3.log_operation( + audit_entry=s3.AuditEntry( + group_name=group_assignment.group_name, + group_id=group_assignment.group_id, + reason="scheduled_revocation", + requester_slack_id=group_revoke_event.requester.id, + requester_email=group_revoke_event.requester.email, + approver_slack_id=group_revoke_event.approver.id, + approver_email=group_revoke_event.approver.email, + operation_type="revoke", + permission_duration=group_revoke_event.permission_duration, + sso_user_principal_id=group_assignment.user_principal_id, + audit_entry_type="group", + ), + ) + schedule.delete_schedule(scheduler_client, group_revoke_event.schedule_name) + if cfg.post_update_to_slack: + slack_notify_user_on_group_access_revoke( + cfg=cfg, + group_assignment=group_assignment, + sso_client=sso_client, + identitystore_client=identitystore_client, + slack_client=slack_client, + ) + + def handle_check_on_inconsistency( # noqa: PLR0913 sso_client: SSOAdminClient, cfg: config.Config, @@ -237,6 +327,7 @@ def handle_check_on_inconsistency( # noqa: PLR0913 principal_type="USER", ) for scheduled_event in scheduled_revoke_events + if isinstance(scheduled_event, ScheduledRevokeEvent) ] for account_assignment in account_assignments: @@ -244,7 +335,9 @@ def handle_check_on_inconsistency( # noqa: PLR0913 account = organizations.describe_account(org_client, account_assignment.account_id) logger.warning("Found an inconsistent account assignment", extra={"account_assignment": account_assignment}) mention = slack_helpers.create_slack_mention_by_principal_id( - account_assignment=account_assignment, + sso_user_id=account_assignment.principal_id + if isinstance(account_assignment, sso.AccountAssignment) + else account_assignment.user_principal_id, sso_client=sso_client, cfg=cfg, identitystore_client=identitystore_client, @@ -269,6 +362,116 @@ def handle_check_on_inconsistency( # noqa: PLR0913 ) +def check_on_groups_inconsistency( # noqa: PLR0913 + identity_store_client: IdentityStoreClient, + sso_client: SSOAdminClient, + scheduler_client: EventBridgeSchedulerClient, + events_client: EventBridgeClient, + cfg: config.Config, + slack_client: slack_sdk.WebClient, +) -> None: + sso_instance_arn = cfg.sso_instance_arn + sso_instance = sso.describe_sso_instance(sso_client, sso_instance_arn) + identity_store_id = sso_instance.identity_store_id + scheduled_revoke_events = schedule.get_scheduled_events(scheduler_client) + group_assignments = sso.get_group_assignments(identity_store_id, identity_store_client, cfg) + group_assignments_from_events = [ + sso.GroupAssignment( + group_name=scheduled_event.revoke_event.group_assignment.group_name, + group_id=scheduled_event.revoke_event.group_assignment.group_id, + user_principal_id=scheduled_event.revoke_event.group_assignment.user_principal_id, + membership_id=scheduled_event.revoke_event.group_assignment.membership_id, + identity_store_id=scheduled_event.revoke_event.group_assignment.identity_store_id, + ) + for scheduled_event in scheduled_revoke_events + if isinstance(scheduled_event, ScheduledGroupRevokeEvent) + ] + for group_assignment in group_assignments: + if group_assignment not in group_assignments_from_events: + logger.warning("Group assignment is not in the scheduled events", extra={"assignment": group_assignment}) + mention = slack_helpers.create_slack_mention_by_principal_id( + sso_user_id=group_assignment.user_principal_id, + sso_client=sso_client, + cfg=cfg, + identitystore_client=identity_store_client, + slack_client=slack_client, + ) + rule = schedule.get_event_brige_rule( + event_brige_client=events_client, rule_name=cfg.sso_elevator_scheduled_revocation_rule_name + ) + next_run_time_or_expression = schedule.check_rule_expression_and_get_next_run(rule) + time_notice = "" + if isinstance(next_run_time_or_expression, datetime): + time_notice = f" The next scheduled revocation is set for {next_run_time_or_expression}." + elif isinstance(next_run_time_or_expression, str): + time_notice = f" The revocation schedule is set as: {next_run_time_or_expression}." # noqa: Q000 + slack_client.chat_postMessage( + channel=cfg.slack_channel_id, + text=( + f"""Inconsistent group assignment detected in { + group_assignment.group_name}-{group_assignment.group_id} for user {mention}.""" + f"The unidentified assignment will be automatically revoked.{time_notice}" + ), + ) + + +def handle_sso_elevator_group_scheduled_revocation( # noqa: PLR0913 + identity_store_client: IdentityStoreClient, + sso_client: SSOAdminClient, + scheduler_client: EventBridgeSchedulerClient, + cfg: config.Config, + slack_client: slack_sdk.WebClient, +) -> None: + sso_instance_arn = cfg.sso_instance_arn + sso_instance = sso.describe_sso_instance(sso_client, sso_instance_arn) + identity_store_id = sso_instance.identity_store_id + scheduled_revoke_events = schedule.get_scheduled_events(scheduler_client) + group_assignments = sso.get_group_assignments(identity_store_id, identity_store_client, cfg) + group_assignments_from_events = [ + sso.GroupAssignment( + group_name=scheduled_event.revoke_event.group_assignment.group_name, + group_id=scheduled_event.revoke_event.group_assignment.group_id, + user_principal_id=scheduled_event.revoke_event.group_assignment.user_principal_id, + membership_id=scheduled_event.revoke_event.group_assignment.membership_id, + identity_store_id=scheduled_event.revoke_event.group_assignment.identity_store_id, + ) + for scheduled_event in scheduled_revoke_events + if isinstance(scheduled_event, ScheduledGroupRevokeEvent) + ] + for group_assignment in group_assignments: + if group_assignment in group_assignments_from_events: + logger.info( + "Group assignment already scheduled for revocation. Skipping.", + extra={"group_assignment": group_assignment}, + ) + continue + else: + sso.remove_user_from_group(group_assignment.identity_store_id, group_assignment.membership_id, identitystore_client) + s3.log_operation( + audit_entry=s3.AuditEntry( + group_name=group_assignment.group_name, + group_id=group_assignment.group_id, + reason="scheduled_revocation", + requester_slack_id="NA", + requester_email="NA", + approver_slack_id="NA", + approver_email="NA", + operation_type="revoke", + permission_duration="NA", + audit_entry_type="group", + sso_user_principal_id=group_assignment.user_principal_id, + ), + ) + if cfg.post_update_to_slack: + slack_notify_user_on_group_access_revoke( + cfg=cfg, + group_assignment=group_assignment, + sso_client=sso_client, + identitystore_client=identitystore_client, + slack_client=slack_client, + ) + + def handle_sso_elevator_scheduled_revocation( # noqa: PLR0913 sso_client: SSOAdminClient, cfg: config.Config, @@ -288,6 +491,7 @@ def handle_sso_elevator_scheduled_revocation( # noqa: PLR0913 principal_type="USER", ) for scheduled_event in scheduled_revoke_events + if isinstance(scheduled_event, ScheduledRevokeEvent) ] for account_assignment in account_assignments: if account_assignment in account_assignments_from_events: diff --git a/src/s3.py b/src/s3.py index 7762e18..0abbd9a 100644 --- a/src/s3.py +++ b/src/s3.py @@ -1,10 +1,11 @@ import json import uuid from dataclasses import asdict, dataclass -from datetime import datetime, timedelta -from mypy_boto3_s3 import S3Client, type_defs +from datetime import datetime, timedelta, timezone +from typing import Literal import boto3 +from mypy_boto3_s3 import S3Client, type_defs from config import get_config, get_logger @@ -15,20 +16,26 @@ @dataclass class AuditEntry: - role_name: str - account_id: str - reason: str - requester_slack_id: str - requester_email: str - request_id: str - approver_slack_id: str - approver_email: str - operation_type: str - permission_duration: str | timedelta + reason: Literal["scheduled_revocation"] | Literal["automated_revocation"] | str + operation_type: Literal["grant"] | Literal["revoke"] + permission_duration: Literal["NA"] | timedelta + sso_user_principal_id: Literal["NA"] | str + audit_entry_type: Literal["group"] | Literal["account"] + version = 1 + role_name: Literal["NA"] | str = "NA" + account_id: Literal["NA"] | str = "NA" + requester_slack_id: Literal["NA"] | str = "NA" + requester_email: Literal["NA"] | str = "NA" + request_id: Literal["NA"] | str = "NA" + approver_slack_id: Literal["NA"] | str = "NA" + approver_email: Literal["NA"] | str = "NA" + group_name: Literal["NA"] | str = "NA" + group_id: Literal["NA"] | str = "NA" + group_membership_id: Literal["NA"] | str = "NA" def log_operation(audit_entry: AuditEntry) -> type_defs.PutObjectOutputTypeDef: - now = datetime.now() + now = datetime.now(timezone.utc) logger.debug("Posting audit entry to s3", extra={"audit_entry": audit_entry}) logger.info("Posting audit entry to s3") if isinstance(audit_entry.permission_duration, timedelta): diff --git a/src/schedule.py b/src/schedule.py index 161f2e7..92213b3 100644 --- a/src/schedule.py +++ b/src/schedule.py @@ -13,7 +13,15 @@ import config import entities import sso -from events import DiscardButtonsEvent, Event, RevokeEvent, ScheduledRevokeEvent, ApproverNotificationEvent +from events import ( + ApproverNotificationEvent, + DiscardButtonsEvent, + Event, + GroupRevokeEvent, + RevokeEvent, + ScheduledRevokeEvent, + ScheduledGroupRevokeEvent, +) logger = config.get_logger(service="schedule") cfg = config.get_config() @@ -34,7 +42,7 @@ def get_next_cron_run_time(cron_expression: str, base_time: datetime) -> datetim def check_rule_expression_and_get_next_run(rule: events_type_defs.DescribeRuleResponseTypeDef) -> datetime | str: schedule_expression = rule["ScheduleExpression"] - current_time = datetime.utcnow() + current_time = datetime.now(timezone.utc) logger.debug(f"Current time: {current_time}") logger.debug(f"Schedule expression: {schedule_expression}") @@ -64,9 +72,10 @@ def get_schedules(client: EventBridgeSchedulerClient) -> list[scheduler_type_def return scheduled_events -def get_scheduled_events(client: EventBridgeSchedulerClient) -> list[ScheduledRevokeEvent]: +def get_scheduled_events(client: EventBridgeSchedulerClient) -> list[ScheduledRevokeEvent | ScheduledGroupRevokeEvent]: scheduled_events = get_schedules(client) - scheduled_revoke_events: list[ScheduledRevokeEvent] = [] + logger.debug("Scheduled events", extra={"scheduled_events": scheduled_events}) + scheduled_revoke_events: list[ScheduledRevokeEvent | ScheduledGroupRevokeEvent] = [] for full_schedule in scheduled_events: if full_schedule["Name"].startswith("discard-buttons"): continue @@ -81,7 +90,9 @@ def get_scheduled_events(client: EventBridgeSchedulerClient) -> list[ScheduledRe if isinstance(event.__root__, ScheduledRevokeEvent): scheduled_revoke_events.append(event.__root__) - + elif isinstance(event.__root__, ScheduledGroupRevokeEvent): + scheduled_revoke_events.append(event.__root__) + logger.debug("Scheduled revoke events", extra={"scheduled_revoke_events": scheduled_revoke_events}) return scheduled_revoke_events @@ -98,10 +109,14 @@ def delete_schedule(client: EventBridgeSchedulerClient, schedule_name: str) -> N def get_and_delete_scheduled_revoke_event_if_already_exist( client: EventBridgeSchedulerClient, - user_account_assignment: sso.UserAccountAssignment, + event: sso.UserAccountAssignment | sso.GroupAssignment, ) -> None: for scheduled_event in get_scheduled_events(client): - if scheduled_event.revoke_event.user_account_assignment == user_account_assignment: + logger.debug("Checking if schedule already exist", extra={"scheduled_event": scheduled_event}) + if isinstance(scheduled_event, ScheduledRevokeEvent) and scheduled_event.revoke_event.user_account_assignment == event: + logger.info("Schedule already exist, deleting it", extra={"schedule_name": scheduled_event.revoke_event.schedule_name}) + delete_schedule(client, scheduled_event.revoke_event.schedule_name) + if isinstance(scheduled_event, ScheduledGroupRevokeEvent) and scheduled_event.revoke_event.group_assignment == event: logger.info("Schedule already exist, deleting it", extra={"schedule_name": scheduled_event.revoke_event.schedule_name}) delete_schedule(client, scheduled_event.revoke_event.schedule_name) @@ -119,7 +134,7 @@ def schedule_revoke_event( user_account_assignment: sso.UserAccountAssignment, ) -> scheduler_type_defs.CreateScheduleOutputTypeDef: logger.info("Scheduling revoke event") - schedule_name = f"{cfg.revoker_function_name}" + datetime.now().strftime("%Y-%m-%d-%H-%M-%S") + schedule_name = f"{cfg.revoker_function_name}" + datetime.now(timezone.utc).strftime("%Y-%m-%d-%H-%M-%S") get_and_delete_scheduled_revoke_event_if_already_exist(schedule_client, user_account_assignment) revoke_event = RevokeEvent( schedule_name=schedule_name, @@ -148,6 +163,43 @@ def schedule_revoke_event( ) +def schedule_group_revoke_event( + schedule_client: EventBridgeSchedulerClient, + permission_duration: timedelta, + approver: entities.slack.User, + requester: entities.slack.User, + group_assignment: sso.GroupAssignment, +) -> scheduler_type_defs.CreateScheduleOutputTypeDef: + logger.info("Scheduling revoke event") + schedule_name = f"{cfg.revoker_function_name}" + datetime.now(timezone.utc).strftime("%Y-%m-%d-%H-%M-%S") + revoke_event = GroupRevokeEvent( + schedule_name=schedule_name, + approver=approver, + requester=requester, + group_assignment=group_assignment, + permission_duration=permission_duration, + ) + get_and_delete_scheduled_revoke_event_if_already_exist(schedule_client, group_assignment) + logger.debug("Creating schedule", extra={"revoke_event": revoke_event}) + return schedule_client.create_schedule( + FlexibleTimeWindow={"Mode": "OFF"}, + Name=schedule_name, + GroupName=cfg.schedule_group_name, + ScheduleExpression=event_bridge_schedule_after(permission_duration), + State="ENABLED", + Target=scheduler_type_defs.TargetTypeDef( + Arn=cfg.revoker_function_arn, + RoleArn=cfg.schedule_policy_arn, + Input=json.dumps( + { + "action": "event_bridge_group_revoke", + "revoke_event": revoke_event.json(), + }, + ), + ), + ) + + def schedule_discard_buttons_event( schedule_client: EventBridgeSchedulerClient, time_stamp: str, @@ -159,7 +211,7 @@ def schedule_discard_buttons_event( permission_duration = timedelta(hours=cfg.request_expiration_hours) logger.info("Scheduling discard buttons event") - schedule_name = "discard-buttons" + datetime.now().strftime("%Y-%m-%d-%H-%M-%S") + schedule_name = "discard-buttons" + datetime.now(timezone.utc).strftime("%Y-%m-%d-%H-%M-%S") logger.debug( "Creating schedule", extra={ @@ -202,7 +254,7 @@ def schedule_approver_notification_event( return logger.info("Scheduling approver notification event") - schedule_name = "approvers-renotification" + datetime.now().strftime("%Y-%m-%d-%H-%M-%S") + schedule_name = "approvers-renotification" + datetime.now(timezone.utc).strftime("%Y-%m-%d-%H-%M-%S") logger.debug( "Creating schedule", extra={ diff --git a/src/slack_helpers.py b/src/slack_helpers.py index 7680a77..c5b2068 100644 --- a/src/slack_helpers.py +++ b/src/slack_helpers.py @@ -1,6 +1,6 @@ import datetime import time -from datetime import timedelta +from datetime import timedelta, timezone from typing import Optional, TypeVar, Union import jmespath as jp @@ -44,7 +44,8 @@ class RequestForAccess(BaseModel): class RequestForAccessView: - CALLBACK_ID = "request_for_access_submitted" + __name__ = "RequestForAccountAccessView" + CALLBACK_ID = "request_for__account_access_submitted" REASON_BLOCK_ID = "provide_reason" REASON_ACTION_ID = "provided_reason" @@ -222,25 +223,28 @@ def unhumanize_timedelta(td_str: str) -> timedelta: def build_approval_request_message_blocks( # noqa: PLR0913 requester_slack_id: str, - account: entities.aws.Account, - role_name: str, + permission_duration: timedelta, reason: str, color_coding_emoji: str, - permission_duration: timedelta, + account: Optional[entities.aws.Account] = None, + group: Optional[entities.aws.SSOGroup] = None, + role_name: Optional[str] = None, show_buttons: bool = True, ) -> list[Block]: + fields = [ + MarkdownTextObject(text=f"Requester: <@{requester_slack_id}>"), + MarkdownTextObject(text=f"Reason: {reason}"), + MarkdownTextObject(text=f"Permission duration: {humanize_timedelta(permission_duration)}"), + ] + if group: + fields.insert(1, MarkdownTextObject(text=f"Group: {group.name} #{group.id}")) + elif account and role_name: + fields.insert(1, MarkdownTextObject(text=f"Account: {account.name} #{account.id}")) + fields.insert(2, MarkdownTextObject(text=f"Role name: {role_name}")) + blocks: list[Block] = [ HeaderSectionBlock.new(color_coding_emoji), - SectionBlock( - block_id="content", - fields=[ - MarkdownTextObject(text=f"Requester: <@{requester_slack_id}>"), - MarkdownTextObject(text=f"Account: {account.name} #{account.id}"), - MarkdownTextObject(text=f"Role name: {role_name}"), - MarkdownTextObject(text=f"Reason: {reason}"), - MarkdownTextObject(text=f"Permission duration: {humanize_timedelta(permission_duration)}"), - ], - ), + SectionBlock(block_id="content", fields=fields), ] if show_buttons: blocks.append( @@ -304,6 +308,7 @@ class Config: @root_validator(pre=True) def validate_payload(cls, values: dict) -> dict: # noqa: ANN101 + message = values["message"] fields = jp.search("message.blocks[?block_id == 'content'].fields[]", values) requester_mention = cls.find_in_fields(fields, "Requester") requester_slack_id = requester_mention.removeprefix("<@").removesuffix(">") @@ -316,7 +321,7 @@ def validate_payload(cls, values: dict) -> dict: # noqa: ANN101 "approver_slack_id": jp.search("user.id", values), "thread_ts": jp.search("message.ts", values), "channel_id": jp.search("channel.id", values), - "message": values.get("message"), + "message": message, "request": RequestForAccess( requester_slack_id=requester_slack_id, account_id=account_id, @@ -346,14 +351,14 @@ def get_user(client: WebClient, id: str) -> entities.slack.User: def get_user_by_email(client: WebClient, email: str) -> entities.slack.User: - start = datetime.datetime.now() + start = datetime.datetime.now(timezone.utc) timeout_seconds = 30 try: r = client.users_lookupByEmail(email=email) return parse_user(r.data) # type: ignore except slack_sdk.errors.SlackApiError as e: if e.response["error"] == "ratelimited": - if datetime.datetime.now() - start >= datetime.timedelta(seconds=timeout_seconds): + if datetime.datetime.now(timezone.utc) - start >= datetime.timedelta(seconds=timeout_seconds): raise e logger.info(f"Rate limited when getting slack user by email. Sleeping for 3 seconds. {e}") time.sleep(3) @@ -375,7 +380,7 @@ def remove_buttons_from_message_blocks( def create_slack_mention_by_principal_id( - account_assignment: sso.AccountAssignment | sso.UserAccountAssignment, + sso_user_id: str, sso_client: SSOAdminClient, cfg: config.Config, identitystore_client: IdentityStoreClient, @@ -385,7 +390,7 @@ def create_slack_mention_by_principal_id( aws_user_emails = sso.get_user_emails( identitystore_client, sso_instance.identity_store_id, - account_assignment.principal_id if isinstance(account_assignment, sso.AccountAssignment) else account_assignment.user_principal_id, + sso_user_id, ) user_name = None @@ -418,3 +423,168 @@ def get_max_duration_block(cfg: config.Config) -> list[Option]: Option(text=PlainTextObject(text=f"{i // 2:02d}:{(i % 2) * 30:02d}"), value=f"{i // 2:02d}:{(i % 2) * 30:02d}") for i in range(1, cfg.max_permissions_duration_time * 2 + 1) ] + + +# Group +# -----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- +# -----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- +# -----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- +# -----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- +# -----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- +# -----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- + + +class RequestForGroupAccess(entities.BaseModel): + group_id: str + reason: str + requester_slack_id: str + permission_duration: timedelta + + +class RequestForGroupAccessView: + __name__ = "RequestForGroupAccessView" + CALLBACK_ID = "request_for_group_access_submitted" + + REASON_BLOCK_ID = "provide_reason" + REASON_ACTION_ID = "provided_reason" + + GROUP_BLOCK_ID = "select_group" + GROUP_ACTION_ID = "selected_group" + + DURATION_BLOCK_ID = "duration_picker" + DURATION_ACTION_ID = "duration_picker_action" + + LOADING_BLOCK_ID = "loading" + + @classmethod + def build(cls) -> View: # noqa: ANN102 + return View( + type="modal", + callback_id=cls.CALLBACK_ID, + submit=PlainTextObject(text="Request"), + close=PlainTextObject(text="Cancel"), + title=PlainTextObject(text="Get AWS access"), + blocks=[ + SectionBlock(text=MarkdownTextObject(text=":wave: Hey! Please fill form below to request access to AWS SSO group.")), + DividerBlock(), + SectionBlock( + block_id=cls.DURATION_BLOCK_ID, + text=MarkdownTextObject(text="Select the duration for which the access will be provided"), + accessory=StaticSelectElement( + action_id=cls.DURATION_ACTION_ID, + initial_option=get_max_duration_block(cfg)[0], + options=get_max_duration_block(cfg), + placeholder=PlainTextObject(text="Select duration"), + ), + ), + InputBlock( + block_id=cls.REASON_BLOCK_ID, + label=PlainTextObject(text="Why do you need access?"), + element=PlainTextInputElement( + action_id=cls.REASON_ACTION_ID, + multiline=True, + ), + ), + DividerBlock(), + SectionBlock( + text=MarkdownTextObject( + text="Remember to use access responsibly. All actions (AWS API calls) are being recorded.", + ), + ), + SectionBlock( + block_id=cls.LOADING_BLOCK_ID, + text=MarkdownTextObject( + text=":hourglass: Loading available accounts and permission sets...", + ), + ), + ], + ) + + @classmethod + def update_with_groups(cls, groups: list[entities.aws.SSOGroup]) -> View: # noqa: ANN102 + view = cls.build() + view.blocks = remove_blocks(view.blocks, block_ids=[cls.LOADING_BLOCK_ID]) + view.blocks = insert_blocks( + blocks=view.blocks, + blocks_to_insert=[ + cls.build_select_group_input_block(groups), + ], + after_block_id=cls.REASON_BLOCK_ID, + ) + return view + + @classmethod + def build_select_group_input_block(cls, groups: list[entities.aws.SSOGroup]) -> InputBlock: # noqa: ANN102 + # TODO: handle case when there are more than 100 groups + # 99 is the limit for StaticSelectElement + # https://slack.dev/python-slack-sdk/api-docs/slack_sdk/models/blocks/block_elements.html#:~:text=StaticSelectElement(InputInteractiveElement)%3A%0A%20%20%20%20type%20%3D%20%22static_select%22-,options_max_length%20%3D%20100,-option_groups_max_length%20%3D%20100%0A%0A%20%20%20%20%40property%0A%20%20%20%20def%20attributes( + if len(groups) > 99: # noqa: PLR2004 + groups = groups[:99] + sorted_groups = sorted(groups, key=lambda groups: groups.name) + return InputBlock( + block_id=cls.GROUP_BLOCK_ID, + label=PlainTextObject(text="Select group"), + element=StaticSelectElement( + action_id=cls.GROUP_ACTION_ID, + placeholder=PlainTextObject(text="Select group"), + options=[Option(text=PlainTextObject(text=f"{group.name}"), value=group.id) for group in sorted_groups], + ), + ) + + @classmethod + def parse(cls, obj: dict) -> RequestForGroupAccess: # noqa: ANN102 + values = jp.search("view.state.values", obj) + hhmm = jp.search(f"{cls.DURATION_BLOCK_ID}.{cls.DURATION_ACTION_ID}.selected_option.value", values) + hours, minutes = map(int, hhmm.split(":")) + duration = timedelta(hours=hours, minutes=minutes) + return RequestForGroupAccess.parse_obj( + { + "permission_duration": duration, + "group_id": jp.search(f"{cls.GROUP_BLOCK_ID}.{cls.GROUP_ACTION_ID}.selected_option.value", values), + "reason": jp.search(f"{cls.REASON_BLOCK_ID}.{cls.REASON_ACTION_ID}.value", values), + "requester_slack_id": jp.search("user.id", obj), + } + ) + + +class ButtonGroupClickedPayload(BaseModel): + action: entities.ApproverAction + approver_slack_id: str + thread_ts: str + channel_id: str + message: dict + request: RequestForGroupAccess + + class Config: + frozen = True + + @root_validator(pre=True) + def validate_payload(cls, values: dict) -> dict: # noqa: ANN101 + message = values["message"] + fields = jp.search("message.blocks[?block_id == 'content'].fields[]", values) + requester_mention = cls.find_in_fields(fields, "Requester") + requester_slack_id = requester_mention.removeprefix("<@").removesuffix(">") + humanized_permission_duration = cls.find_in_fields(fields, "Permission duration") + permission_duration = unhumanize_timedelta(humanized_permission_duration) + group = cls.find_in_fields(fields, "Group") + group_id = group.split("#")[-1] + return { + "action": jp.search("actions[0].value", values), + "approver_slack_id": jp.search("user.id", values), + "thread_ts": jp.search("message.ts", values), + "channel_id": jp.search("channel.id", values), + "message": message, + "request": RequestForGroupAccess( + requester_slack_id=requester_slack_id, + group_id=group_id, + reason=cls.find_in_fields(fields, "Reason"), + permission_duration=permission_duration, + ), + } + + @staticmethod + def find_in_fields(fields: list[dict[str, str]], key: str) -> str: + for field in fields: + if field["text"].startswith(key): + return field["text"].split(": ")[1].strip() + raise ValueError(f"Failed to parse message. Could not find {key} in fields: {fields}") diff --git a/src/sso.py b/src/sso.py index 986906d..578db2b 100644 --- a/src/sso.py +++ b/src/sso.py @@ -1,9 +1,10 @@ from __future__ import annotations import datetime +from datetime import timezone import time from dataclasses import dataclass -from typing import TYPE_CHECKING, Callable, Generator, Optional, TypeVar +from typing import TYPE_CHECKING, Any, Callable, Dict, Generator, Optional, TypeVar import config import entities @@ -12,6 +13,7 @@ if TYPE_CHECKING: from mypy_boto3_identitystore import IdentityStoreClient + from mypy_boto3_identitystore import type_defs as idc_type_defs from mypy_boto3_organizations import OrganizationsClient from mypy_boto3_sso_admin import SSOAdminClient, type_defs @@ -80,6 +82,15 @@ def as_dict(self: UserAccountAssignment) -> dict: } +@dataclass +class GroupAssignment: + group_name: str + group_id: str + user_principal_id: str + membership_id: str + identity_store_id: str + + def create_account_assignment(client: SSOAdminClient, assignment: UserAccountAssignment) -> AccountAssignmentStatus: response = client.create_account_assignment(**assignment.as_dict()) return AccountAssignmentStatus.from_type_def(response["AccountAssignmentCreationStatus"]) @@ -117,12 +128,12 @@ def retry_while( timeout_seconds: int = 20, ) -> T: # If timeout_seconds -1, then retry forever. - start = datetime.datetime.now() + start = datetime.datetime.now(timezone.utc) def is_timeout(timeout_seconds: int) -> bool: if timeout_seconds == -1: return False - return datetime.datetime.now() - start >= datetime.timedelta(seconds=timeout_seconds) + return datetime.datetime.now(timezone.utc) - start >= datetime.timedelta(seconds=timeout_seconds) while True: response = fn() @@ -333,13 +344,123 @@ def get_permission_sets_from_config(client: SSOAdminClient, cfg: config.Config) def get_account_assignment_information( sso_client: SSOAdminClient, cfg: config.Config, org_client: OrganizationsClient ) -> list[AccountAssignment]: - describe_sso_instance(sso_client, cfg.sso_instance_arn) accounts = organizations.get_accounts_from_config(org_client, cfg) permission_sets = get_permission_sets_from_config(sso_client, cfg) - account_assignments = list_user_account_assignments( + return list_user_account_assignments( sso_client, cfg.sso_instance_arn, [a.id for a in accounts], [ps.arn for ps in permission_sets], ) - return account_assignments + + +# -----------------Group Assignments-----------------# + + +def get_groups_from_config( + identity_store_id: str, identity_store_client: IdentityStoreClient, cfg: config.Config +) -> list[entities.aws.SSOGroup]: + logger.info("Getting groups from config") + try: + groups = [] + for page in identity_store_client.get_paginator("list_groups").paginate(IdentityStoreId=identity_store_id): + groups.extend( + entities.aws.SSOGroup( + id=group.get("GroupId"), + identity_store_id=group.get("IdentityStoreId"), + name=group.get("DisplayName"), # type: ignore # noqa: PGH003 + description=group.get("Description"), + ) + for group in page["Groups"] + if group.get("DisplayName") and group.get("GroupId") in cfg.groups + ) + groups = sorted(groups, key=lambda g: g.name) + logger.debug("Groups", extra={"groups": groups}) + return groups + except Exception as e: + logger.error("Error while getting groups from config", extra={"error": e}) + raise e + + +def add_user_to_a_group( + sso_group_id: str, sso_user_id: str, identity_store_id: str, identity_store_client: IdentityStoreClient +) -> idc_type_defs.CreateGroupMembershipResponseTypeDef: + responce = identity_store_client.create_group_membership( + GroupId=sso_group_id, MemberId={"UserId": sso_user_id}, IdentityStoreId=identity_store_id + ) + logger.info( + "User added to the group", + extra={ + "group_id": sso_group_id, + "user_id": sso_user_id, + }, + ) + return responce + + +def remove_user_from_group(identity_store_id: str, membership_id: str, identity_store_client: IdentityStoreClient) -> Dict[str, Any]: + responce = identity_store_client.delete_group_membership(IdentityStoreId=identity_store_id, MembershipId=membership_id) + logger.info("User removed from the group", extra={"membership_id": membership_id}) + logger.debug("User removed from the group", extra={"responce": responce}) + return responce + + +def list_group_memberships( + identity_store_id: str, group_id: str, identity_store_client: IdentityStoreClient +) -> list[entities.aws.GroupMembership]: + logger.info("Listing group memberships") + paginator = identity_store_client.get_paginator("list_group_memberships") + group_memberships = [] + for page in paginator.paginate(IdentityStoreId=identity_store_id, GroupId=group_id): + memberships = page["GroupMemberships"] + group_memberships.extend( + entities.aws.GroupMembership( + user_principal_id=membership["MemberId"]["UserId"], # type: ignore # noqa: PGH003 + group_id=membership["GroupId"], # type: ignore # noqa: PGH003 + identity_store_id=membership["IdentityStoreId"], # type: ignore # noqa: PGH003 + membership_id=membership["MembershipId"], # type: ignore # noqa: PGH003 + ) + for membership in memberships + ) + logger.debug("Group memberships", extra={"group_memberships": group_memberships}) + return group_memberships + + +def is_user_in_group(identity_store_id: str, group_id: str, sso_user_id: str, identity_store_client: IdentityStoreClient) -> str | None: + group_memberships = list_group_memberships(identity_store_id, group_id, identity_store_client) + for member in group_memberships: + if member.user_principal_id == sso_user_id: # type: ignore # noqa: PGH003 + logger.info("User is in the group", extra={"group": member}) + return member["MembershipId"] # type: ignore # noqa: PGH003 (ignoring this because we checked if user is in the group) + return None + + +def describe_group(identity_store_id: str, group_id: str, identity_store_client: IdentityStoreClient) -> entities.aws.SSOGroup: + group = identity_store_client.describe_group(IdentityStoreId=identity_store_id, GroupId=group_id) + logger.info("Group described", extra={"group": group}) + return entities.aws.SSOGroup( + id=group.get("GroupId"), + identity_store_id=group.get("IdentityStoreId"), + name=group.get("DisplayName"), # type: ignore # noqa: PGH003 + description=group.get("Description"), + ) + + +def get_group_assignments(identity_store_id: str, identity_store_client: IdentityStoreClient, cfg: config.Config) -> list[GroupAssignment]: + logger.info("Getting group assignments") + groups = get_groups_from_config(identity_store_id, identity_store_client, cfg) + group_assignments = [] + for group in groups: + group_memberships = list_group_memberships(identity_store_id, group.id, identity_store_client) + group_assignments.extend( + GroupAssignment( + group_name=group.name, + group_id=group.id, + user_principal_id=membership.user_principal_id, + membership_id=membership.membership_id, + identity_store_id=membership.identity_store_id, + ) + for membership in group_memberships + ) + logger.debug("Group assignments", extra={"group_assignments": group_assignments}) + return group_assignments diff --git a/src/statement.py b/src/statement.py index c49b072..b8b8b8c 100644 --- a/src/statement.py +++ b/src/statement.py @@ -52,3 +52,21 @@ def get_affected_statements(statements: FrozenSet[Statement], account_id: str, p class OUStatement(BaseStatement): resource_type: ResourceType = Field(ResourceType.OU, const=True) resource: FrozenSet[Union[AWSOUName, WildCard]] + + +class AWSSSOGroupID(ConstrainedStr): + regex = r"^([0-9a-f]{10}-)?[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}$" + + +class GroupStatement(BaseModel): + resource: FrozenSet[AWSSSOGroupID] + allow_self_approval: bool | None = None + approval_is_not_required: bool | None = None + approvers: FrozenSet[EmailStr] = Field(default_factory=frozenset) + + def affects(self, group_id: str) -> bool: # noqa: ANN101 + return group_id in self.resource + + +def get_affected_group_statements(statements: FrozenSet[GroupStatement], group_id: str) -> FrozenSet[GroupStatement]: + return frozenset(statement for statement in statements if statement.affects(group_id)) diff --git a/src/tests/conftest.py b/src/tests/conftest.py index 4149d70..7702d1a 100644 --- a/src/tests/conftest.py +++ b/src/tests/conftest.py @@ -1,7 +1,7 @@ import os import boto3 - +import json def pytest_sessionstart(session): # noqa: ANN201, ARG001, ANN001 mock_env = { @@ -22,6 +22,28 @@ def pytest_sessionstart(session): # noqa: ANN201, ARG001, ANN001 "approver_renotification_initial_wait_time": "15", "approver_renotification_backoff_multiplier": "2", "max_permissions_duration_time": "24", + "statements": json.dumps( + [ + { + "ResourceType" : "Account", + "Resource" : ["*"], + "PermissionSet" : "*", + "Approvers" : ["email@domen.com",], + "AllowSelfApproval" : True, + } + ] + ), + "group_statements": json.dumps( + [ + { + "Resource" : ["11111111-2222-3333-4444-555555555555"], + "Approvers" : [ + "email@domen.com" + ], + "AllowSelfApproval" : True, + }, + ] + ) } os.environ |= mock_env diff --git a/src/tests/strategies.py b/src/tests/strategies.py index b27e5b3..0f5fb4f 100644 --- a/src/tests/strategies.py +++ b/src/tests/strategies.py @@ -14,10 +14,35 @@ def jsonstr(strategy: SearchStrategy) -> SearchStrategy: strategy, ) +def build_group_id_strategy(): + lover_alphabet_group_id = ["a", "b", "c", "d", "e", "f", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9"] + full_alphabet_group_id = lover_alphabet_group_id + ["A", "B", "C", "D", "E", "F"] + + first_ten = st.one_of( + st.text(min_size=0, max_size=0), + st.text(min_size=10, max_size=10, alphabet=lover_alphabet_group_id) + ) + second_part = st.text(min_size=8, max_size=8, alphabet=full_alphabet_group_id) + third_part = st.text(min_size=4, max_size=4, alphabet=full_alphabet_group_id) + fourth_part = st.text(min_size=4, max_size=4, alphabet=full_alphabet_group_id) + fifth_part = st.text(min_size=4, max_size=4, alphabet=full_alphabet_group_id) + sixth_part = st.text(min_size=12, max_size=12, alphabet=full_alphabet_group_id) + + return st.builds( + lambda first, second, third, fourth, fifth, sixth: f"{first}{('-' if first else '')}{second}-{third}-{fourth}-{fifth}-{sixth}", + first_ten, + second_part, + third_part, + fourth_part, + fifth_part, + sixth_part + ) +group_id = build_group_id_strategy() # https://docs.aws.amazon.com/organizations/latest/APIReference/API_CreateAccountStatus.html aws_account_id = st.text(min_size=12, max_size=12, alphabet=string.digits) + # https://docs.aws.amazon.com/singlesignon/latest/APIReference/API_CreatePermissionSet.html#singlesignon-CreatePermissionSet-request-Name aws_permission_set_name = st.text(min_size=1, max_size=32, alphabet=string.ascii_letters + string.digits + "_+=,.@-") @@ -74,8 +99,26 @@ def statement_dict( ), }, optional={ - "Approvers": st.one_of(st.emails(), st.lists(st.emails(), max_size=20)), + "Approvers": st.one_of(st.emails(), st.lists(st.emails(), max_size=20)), #type: ignore no "ApprovalIsNotRequired": st.booleans(), "AllowSelfApproval": st.booleans(), }, ) + + +@st.composite +def group_resource(draw: st.DrawFn): + return draw(group_id) + +def group_statement_dict(): + resource_strategy = group_resource() + return st.fixed_dictionaries( + mapping={ + "Resource": st.one_of(resource_strategy, st.lists(resource_strategy, max_size=20)), + }, + optional={ + "Approvers": st.one_of(st.emails(), st.lists(st.emails(), max_size=20)), + "ApprovalIsNotRequired": st.booleans(), + "AllowSelfApproval": st.booleans(), + },# type: ignore # noqa: PGH003 + ) diff --git a/src/tests/test_config.py b/src/tests/test_config.py index 8880d22..a958059 100644 --- a/src/tests/test_config.py +++ b/src/tests/test_config.py @@ -1,7 +1,7 @@ import json import os -from hypothesis import example, given, settings +from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st from hypothesis.strategies import SearchStrategy from pydantic import ValidationError @@ -17,20 +17,40 @@ "PermissionSet": "AdministratorAccess", "Approvers": "example@gmail.com", } +VALID_GROUP_STATEMENT_DICT = { + "Resource": ["11e111e1-e111-11ee-e111-1e11e1ee11e1"], + "Approvers": "example@gmail.com", + "AllowSelfApproval": True, +} + @given(strategies.statement_dict()) -@settings(max_examples=100) +@settings(max_examples=50, suppress_health_check=(HealthCheck.too_slow,)) @example({}).xfail(raises=KeyError, reason="Empty dict is not a valid statement") @example(VALID_STATEMENT_DICT) -def test_parse_statement(dict_statement: dict): +def test_parse_statement(dict_statement: dict,): try: config.parse_statement(dict_statement) except ValidationError: assert False -def config_dict(statements: SearchStrategy = strategies.jsonstr(st.lists(strategies.statement_dict()))): +@given(strategies.group_statement_dict()) +@settings(max_examples=50, suppress_health_check=(HealthCheck.too_slow,)) +@example({}).xfail(raises=KeyError, reason="Empty dict is not a valid group_statement") +@example(VALID_GROUP_STATEMENT_DICT) +def test_parse_group_statement(dict_group_statement: dict): + try: + config.parse_group_statement(dict_group_statement) + except ValidationError: + assert False + + +def config_dict( + statements: SearchStrategy = strategies.jsonstr(st.lists(strategies.statement_dict())), + group_statements: SearchStrategy = strategies.jsonstr(st.lists(strategies.group_statement_dict())), +): return st.fixed_dictionaries( { "schedule_policy_arn": strategies.json_safe_text, @@ -46,6 +66,7 @@ def config_dict(statements: SearchStrategy = strategies.jsonstr(st.lists(strateg "log_level": st.one_of(st.just("INFO"), st.just("DEBUG"), st.just("WARNING"), st.just("ERROR"), st.just("CRITICAL")), "post_update_to_slack": strategies.str_bool, "statements": statements, + "group_statements": group_statements, "request_expiration_hours": st.integers(min_value=0, max_value=24), "approver_renotification_initial_wait_time": st.integers(min_value=0, max_value=60), "approver_renotification_backoff_multiplier": st.integers(min_value=0, max_value=10), @@ -54,11 +75,20 @@ def config_dict(statements: SearchStrategy = strategies.jsonstr(st.lists(strateg ) -def valid_config_dict(statements_as_json: bool = True): + +def valid_config_dict( + statements_as_json: bool = True, + group_statements_as_json: bool = True +): if statements_as_json: statements = json.dumps([VALID_STATEMENT_DICT]) else: statements = [VALID_STATEMENT_DICT] + + if group_statements_as_json: + group_statements = json.dumps([VALID_GROUP_STATEMENT_DICT]) + else: + group_statements = [VALID_GROUP_STATEMENT_DICT] return { "schedule_policy_arn": "x", "revoker_function_arn": "x", @@ -70,6 +100,7 @@ def valid_config_dict(statements_as_json: bool = True): "log_level": "INFO", "post_update_to_slack": "False", "statements": statements, + "group_statements": group_statements, "s3_bucket_for_audit_entry_name": "x", "s3_bucket_prefix_for_partitions": "x", "sso_elevator_scheduled_revocation_rule_name": "x", @@ -84,15 +115,20 @@ def valid_config_dict(statements_as_json: bool = True): @example(valid_config_dict()) @example({}).xfail(raises=ValidationError, reason="Empty dict is not a valid config") @example(valid_config_dict() | {"post_update_to_slack": "x"}).xfail(raises=ValidationError, reason="Invalid bool") -@settings(max_examples=50) +@settings(max_examples=50, suppress_health_check=(HealthCheck.too_slow,)) def test_config_load_environment_variables(dict_config: dict): os.environ = dict_config config.Config() # type: ignore -@given(config_dict(statements=st.lists(strategies.statement_dict(), max_size=20))) -@settings(max_examples=50) -@example(valid_config_dict(statements_as_json=False)) -@example(valid_config_dict(statements_as_json=False) | {"post_update_to_slack": "x"}).xfail(raises=ValidationError, reason="Invalid bool") +@given( + config_dict( + statements=st.lists(strategies.statement_dict(), max_size=20), + group_statements=st.lists(strategies.group_statement_dict(), max_size=20), + ) +) +@settings(max_examples=50, suppress_health_check=(HealthCheck.too_slow,)) +@example(valid_config_dict(statements_as_json=False, group_statements_as_json=False)) +@example(valid_config_dict(statements_as_json=False, group_statements_as_json=False) | {"post_update_to_slack": "x"}).xfail(raises=ValidationError, reason="Invalid bool") def test_config_init(dict_config: dict): config.Config(**dict_config) diff --git a/vars.tf b/vars.tf index 6563cb9..f2de2af 100644 --- a/vars.tf +++ b/vars.tf @@ -87,6 +87,11 @@ variable "config" { type = any } +variable "group_config" { + description = "value for the SSO Elevator group config" + type = any +} + variable "revoker_lambda_name" { description = "value for the revoker lambda name" type = string