From 1d49401c5a204d269d4d635c4adfeaddb86f424f Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Mon, 26 Aug 2024 16:04:28 +0500 Subject: [PATCH 01/64] feat: group access (dump work in progress) --- perm_revoker_lambda.tf | 10 + slack_handler_lambda.tf | 11 + src/access_control.py | 25 +- src/config.py | 20 +- src/entities/aws.py | 6 + src/events.py | 27 +- src/main.py | 17 +- src/revoker.py | 84 ++++- src/s3.py | 21 +- src/schedule.py | 14 +- src/slack_helpers.py | 4 +- src/sso.py | 13 +- src/statement.py | 17 + src/test.py | 745 ++++++++++++++++++++++++++++++++++++++++ 14 files changed, 989 insertions(+), 25 deletions(-) create mode 100644 src/test.py diff --git a/perm_revoker_lambda.tf b/perm_revoker_lambda.tf index c4cc3a0..4d40f28 100644 --- a/perm_revoker_lambda.tf +++ b/perm_revoker_lambda.tf @@ -155,6 +155,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..dc29958 100644 --- a/slack_handler_lambda.tf +++ b/slack_handler_lambda.tf @@ -206,6 +206,17 @@ data "aws_iam_policy_document" "slack_handler" { ] resources = ["*"] } + statement { + effect = "Allow" + actions = [ + "identitystore:ListGroups", + "identitystore:DescribeGroup", + "identitystore:ListGroupMemberships", + "identitystore:CreateGroupMembership", + "identitystore:DeleteGroupMembership" + ] + resources = ["*"] + } } module "http_api" { diff --git a/src/access_control.py b/src/access_control.py index 568f946..5558bb4 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,29 @@ 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 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() + if isinstance(statements, FrozenSet) and all(isinstance(item, Statement) for item in statements): + affected_statements = 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): + affected_statements = 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" + else: + raise TypeError("Statements contain mixed or unsupported types.") + + decision_based_on_statements: set[Statement] | set[GroupStatement] = set() potential_approvers = set() explicit_deny_self_approval = any( diff --git a/src/config.py b/src/config.py index 69f45f1..1377508 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: @@ -25,6 +25,21 @@ 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"), + } + ) + class Config(BaseSettings): schedule_policy_arn: str @@ -44,6 +59,7 @@ 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] @@ -67,6 +83,7 @@ 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 permission_sets = set() accounts = set() for statement in statements: @@ -77,6 +94,7 @@ 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), } diff --git a/src/entities/aws.py b/src/entities/aws.py index 454c29d..928dfcf 100644 --- a/src/entities/aws.py +++ b/src/entities/aws.py @@ -12,3 +12,9 @@ class PermissionSet(BaseModel): name: str arn: str description: Optional[str] + +class SSOGroup(BaseModel): + name: str + id: str + description: Optional[str] + identity_store_id: str diff --git a/src/events.py b/src/events.py index a31fe3e..5deceb1 100644 --- a/src/events.py +++ b/src/events.py @@ -16,6 +16,26 @@ class RevokeEvent(BaseModel): permission_duration: timedelta + +class GroupRevokeEvent(BaseModel): + action: Literal["event_bridge_group_revoke"] + 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 +71,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/main.py b/src/main.py index f9036e4..be11a5e 100644 --- a/src/main.py +++ b/src/main.py @@ -16,6 +16,7 @@ import schedule import slack_helpers import sso +import test logger = config.get_logger(service="main") @@ -95,6 +96,10 @@ def load_select_options(client: WebClient, body: dict) -> SlackResponse: load_select_options, ) +app.shortcut("request_for_group_membership")( + test.show_initial_form, + test.load_select_options, +) cache_for_dublicate_requests = {} @@ -102,7 +107,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 test.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 +328,11 @@ def handle_request_for_access_submittion( lazy=[handle_request_for_access_submittion], ) +app.view(test.RequestForGroupAccessView.CALLBACK_ID)( + ack=acknowledge_request, + lazy=[test.handle_request_for_group_access_submittion], +) + @app.action("duration_picker_action") def handle_duration_picker_action(ack): # noqa: ANN201, ANN001 diff --git a/src/revoker.py b/src/revoker.py index 1ae3599..9dce588 100644 --- a/src/revoker.py +++ b/src/revoker.py @@ -25,7 +25,10 @@ RevokeEvent, ScheduledRevokeEvent, SSOElevatorScheduledRevocation, + ScheduledGroupRevokeEvent, + GroupRevokeEvent ) +import test logger = config.get_logger(service="revoker") @@ -59,6 +62,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) @@ -155,7 +169,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 +183,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, @@ -218,6 +254,44 @@ 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 account assignment deletion", extra={"revoke_event": GroupRevokeEvent}) + group_assignment = group_revoke_event.group_assignment + test.remove_user_from_group(group_assignment.identity_store_id, group_assignment.membership_id, identitystore_client) + # TODO: Add logging + s3.log_operation( + audit_entry=s3.GroupAccessAuditEntry( + group_name = group_assignment.group_name, + group_id = group_assignment.group_id, + membership_id = group_assignment.membership_id, # type: ignore # noqa: PGH003 + 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, + ), + ) + 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, @@ -236,7 +310,7 @@ def handle_check_on_inconsistency( # noqa: PLR0913 principal_id=scheduled_event.revoke_event.user_account_assignment.user_principal_id, principal_type="USER", ) - for scheduled_event in scheduled_revoke_events + for scheduled_event in scheduled_revoke_events if isinstance(scheduled_event, ScheduledRevokeEvent) ] for account_assignment in account_assignments: @@ -244,7 +318,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, @@ -287,7 +363,7 @@ def handle_sso_elevator_scheduled_revocation( # noqa: PLR0913 principal_id=scheduled_event.revoke_event.user_account_assignment.user_principal_id, principal_type="USER", ) - for scheduled_event in scheduled_revoke_events + 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..c9b31b1 100644 --- a/src/s3.py +++ b/src/s3.py @@ -7,7 +7,7 @@ import boto3 from config import get_config, get_logger - +from typing import Literal cfg = get_config() logger = get_logger(service="s3") s3: S3Client = boto3.client("s3") @@ -26,8 +26,25 @@ class AuditEntry: operation_type: str permission_duration: str | timedelta +@dataclass +class GroupAccessAuditEntry: + group_name: str + group_id: str + membership_id: str | None + reason: str + requester_slack_id: str + requester_email: str + approver_slack_id: str + approver_email: str + operation_type: Literal["grant"] | Literal["revoke"] + permission_duration: str | timedelta + audit_entry_type: Literal["group"] | Literal["account"] + user_principal_id: str + version = 1 +# Where we don't have info, we will write "NA" symbols + -def log_operation(audit_entry: AuditEntry) -> type_defs.PutObjectOutputTypeDef: +def log_operation(audit_entry: AuditEntry | GroupAccessAuditEntry) -> type_defs.PutObjectOutputTypeDef: now = datetime.now() logger.debug("Posting audit entry to s3", extra={"audit_entry": audit_entry}) logger.info("Posting audit entry to s3") diff --git a/src/schedule.py b/src/schedule.py index 161f2e7..72a8363 100644 --- a/src/schedule.py +++ b/src/schedule.py @@ -13,7 +13,7 @@ import config import entities import sso -from events import DiscardButtonsEvent, Event, RevokeEvent, ScheduledRevokeEvent, ApproverNotificationEvent +from events import ApproverNotificationEvent, DiscardButtonsEvent, Event, GroupRevokeEvent, RevokeEvent, ScheduledRevokeEvent logger = config.get_logger(service="schedule") cfg = config.get_config() @@ -64,9 +64,9 @@ 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 | GroupRevokeEvent]: scheduled_events = get_schedules(client) - scheduled_revoke_events: list[ScheduledRevokeEvent] = [] + scheduled_revoke_events: list[ScheduledRevokeEvent | GroupRevokeEvent] = [] for full_schedule in scheduled_events: if full_schedule["Name"].startswith("discard-buttons"): continue @@ -98,12 +98,16 @@ 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 | GroupRevokeEvent, ) -> None: for scheduled_event in get_scheduled_events(client): - if scheduled_event.revoke_event.user_account_assignment == user_account_assignment: + 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, GroupRevokeEvent) and scheduled_event.group_assignment == event: + logger.info("Schedule already exist, deleting it", extra={"schedule_name": scheduled_event.schedule_name}) + delete_schedule(client, scheduled_event.schedule_name) + def event_bridge_schedule_after(td: timedelta) -> str: diff --git a/src/slack_helpers.py b/src/slack_helpers.py index 7680a77..b5d23ef 100644 --- a/src/slack_helpers.py +++ b/src/slack_helpers.py @@ -375,7 +375,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 +385,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 diff --git a/src/sso.py b/src/sso.py index 986906d..39c4bd8 100644 --- a/src/sso.py +++ b/src/sso.py @@ -80,6 +80,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"]) @@ -333,10 +342,10 @@ 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) + describe_sso_instance(sso_client, cfg.sso_instance_arn) # TODO: Remove this line 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( + account_assignments = list_user_account_assignments( # TODO: move to the return block sso_client, cfg.sso_instance_arn, [a.id for a in accounts], diff --git a/src/statement.py b/src/statement.py index c49b072..ccedc6b 100644 --- a/src/statement.py +++ b/src/statement.py @@ -52,3 +52,20 @@ 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/test.py b/src/test.py new file mode 100644 index 0000000..88b5afa --- /dev/null +++ b/src/test.py @@ -0,0 +1,745 @@ +import datetime +import functools +import json +from datetime import datetime, timedelta + +import boto3 +import jmespath as jp +from aws_lambda_powertools import Logger +from mypy_boto3_identitystore import IdentityStoreClient +from mypy_boto3_scheduler import EventBridgeSchedulerClient +from mypy_boto3_scheduler import type_defs as scheduler_type_defs +from mypy_boto3_sso_admin import SSOAdminClient +from pydantic import BaseModel, root_validator +from slack_bolt import Ack, App, BoltContext +from slack_bolt.adapter.socket_mode import SocketModeHandler +from slack_sdk import WebClient +from slack_sdk.models.blocks import ( + ActionsBlock, + Block, + ButtonElement, + DividerBlock, + InputBlock, + MarkdownTextObject, + Option, + PlainTextInputElement, + PlainTextObject, + SectionBlock, + StaticSelectElement, +) +from slack_sdk.models.views import View +from slack_sdk.web.slack_response import SlackResponse + +import access_control +import config +import entities +import errors +import events +import s3 +import schedule +import slack_helpers +import sso +from entities import BaseModel +from slack_helpers import unhumanize_timedelta +from access_control import AccessRequestDecision, ApproveRequestDecision +import creds + +# temporary mock +#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- +#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- +#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- + +logger = config.get_logger(service="main") + +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." + 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 + + + +cfg = config.get_config() +app = App(token=creds.bot_token) + + + + +# SSO +#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- +#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- +#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- +#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- +#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- +#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- + +identity_store_client: IdentityStoreClient = boto3.client("identitystore", region_name="us-east-1") +sso_client: SSOAdminClient = boto3.client("sso-admin", region_name="us-east-1") +schedule_client = boto3.client("scheduler", region_name="us-east-1") + +sso_instance = sso.describe_sso_instance(sso_client, cfg.sso_instance_arn) + +identity_store_id = sso_instance.identity_store_id + + +@handle_errors +def get_all_groups(identity_store_id, identity_store_client: IdentityStoreClient) -> list[entities.aws.SSOGroup]: # noqa: ANN102 ANN001 + 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") + ) + # TODO: handle case when there are no groups + logger.info("Got information about all groups.") + logger.debug("Groups", extra={"groups": groups}) + return groups + +@handle_errors +def add_user_to_a_group(sso_group_id, sso_user_id, identity_store_id, identity_store_client:IdentityStoreClient): # noqa: ANN201 ANN001 + 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 + +@handle_errors +def remove_user_from_group(identity_store_id, membership_id, identity_store_client: IdentityStoreClient): # noqa: ANN201 ANN001 + 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}) + return responce + +@handle_errors +def is_user_in_group(identity_store_id: str, group_id: str, sso_user_id: str, identity_store_client: IdentityStoreClient) -> str | None: + paginator = identity_store_client.get_paginator("list_group_memberships") + for page in paginator.paginate(IdentityStoreId=identity_store_id, GroupId=group_id): + for group in page["GroupMemberships"]: + try: + if group["MemberId"]["UserId"] == sso_user_id: # type: ignore # noqa: PGH003 + logger.info("User is in the group", extra={"group": group}) + return group["MembershipId"] # type: ignore # noqa: PGH003 (ignoring this because we checked if user is in the group) + except Exception as e: + logger.error("Error while checking if user is in the group", extra={"error": e}) + return None + +def describe_group(identity_store_id, group_id, identity_store_client: IdentityStoreClient) -> entities.aws.SSOGroup: # noqa: ANN201 ANN001 + 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"), + ) + +#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- +#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- +#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- +#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- +#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- +#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- +#Main + +trigger_view_map = {} +@handle_errors +def show_initial_form(client: WebClient, body: dict, ack: Ack) -> SlackResponse | None: + ack() + logger.info("Showing initial form for group access") + logger.debug("Request body", extra={"body": body}) + trigger_id = body["trigger_id"] + response = client.views_open(trigger_id=trigger_id, view=RequestForGroupAccessView.build()) + trigger_view_map[trigger_id] = response.data["view"]["id"] # type: ignore # noqa: PGH003 + return response + + +@handle_errors +def load_select_options(client: WebClient, body: dict) -> SlackResponse: + groups = get_all_groups(identity_store_id, identity_store_client) + + trigger_id = body["trigger_id"] + + view = RequestForGroupAccessView.update_with_groups(groups=groups) + return client.views_update(view_id=trigger_view_map[trigger_id], view=view) + +app.shortcut("request_for_group_membership")( + show_initial_form, + load_select_options, +) + + + + + +@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 = RequestForGroupAccessView.parse(body) + logger.info("View submitted", extra={"view": request}) + requester = slack_helpers.get_user(client, id=request.requester_slack_id) + + group = 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 = False # TODO: implement this + slack_response = client.chat_postMessage( + blocks=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) + + execute_decision( + group = group, + user_principal_id = user_principal_id, + permission_duration = request.permission_duration, + approver = requester, + requester = requester, + reason = request.reason, + decision = decision + ) + + 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 = ButtonGroupClickedPayload.parse_obj(body) + logger.info("Button click payload", extra={"payload": payload}) + approver = slack_helpers.get_user(client, id=payload.approver_slack_id) + 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.statements, + # account_id=payload.request.account_id, + # permission_set_name=payload.request.permission_set_name, + # 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( + # decision=decision, + # permission_set_name=payload.request.permission_set_name, + # account_id=payload.request.account_id, + # permission_duration=payload.request.permission_duration, + # approver=approver, + # requester=requester, + # reason=payload.request.reason, + # ) + # cache_for_dublicate_requests.clear() + # return client.chat_postMessage( + # channel=payload.channel_id, + # text=text, + # thread_ts=payload.thread_ts, + # ) + + + + + +# Access control +#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- +#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- +#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- +#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- +#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- +#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- + + + + +def execute_decision( # noqa: PLR0913 + decision: AccessRequestDecision | ApproveRequestDecision, + group: entities.aws.SSOGroup, + user_principal_id: str, + permission_duration: timedelta, + approver: entities.slack.User, + requester: entities.slack.User, + reason: str, +) -> bool: + logger.info("Executing decision") + if not decision.grant: + logger.info("Access request denied") + return False # Temporary solution for testing + + + responce = add_user_to_a_group(group.id, user_principal_id, identity_store_id, identity_store_client) + logger.info("User added to the group", extra={"group_id": group.id, "user_id": user_principal_id, }) + + s3.log_operation( + audit_entry=s3.GroupAccessAuditEntry( + group_name = group.name, + group_id = group.id, + membership_id = responce["MembershipId"], + reason = reason, + requester_slack_id = requester.id, + requester_email = requester.email, + approver_slack_id = "N/A", + approver_email = "N/A", + operation_type = "grant", + permission_duration = permission_duration, + audit_entry_type = "group", + user_principal_id = "" + ), + ) + + 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=responce["MembershipId"], + ), + ) + return# type: ignore # noqa: PGH003 + + + + +#Schedule +#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- +#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- +#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- +#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- +#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- +#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- + + + +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().strftime("%Y-%m-%d-%H-%M-%S") + revoke_event = events.GroupRevokeEvent( + action="event_bridge_group_revoke", + schedule_name=schedule_name, + approver=approver, + requester=requester, + group_assignment= group_assignment, + permission_duration=permission_duration, + ) + schedule.get_and_delete_scheduled_revoke_event_if_already_exist(schedule_client, revoke_event) + 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=schedule.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(), + }, + ), + ), + ) + + +#Slack +#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- +#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- +#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- +#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- +#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- +#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- + + + +class RequestForGroupAccess(entities.BaseModel): + group_id: str + reason: str + requester_slack_id: str + permission_duration: timedelta + +class 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=slack_helpers.get_max_duration_block(cfg)[0], + options=slack_helpers.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] # noqa: ANN102 + ) -> View: + view = cls.build() + view.blocks = slack_helpers.remove_blocks(view.blocks, block_ids=[cls.LOADING_BLOCK_ID]) + view.blocks = slack_helpers.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 + 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": values.get("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}") + + + + + +def build_approval_request_message_blocks( # noqa: PLR0913 + requester_slack_id: str, + group: entities.aws.SSOGroup, + reason: str, + color_coding_emoji: str, + permission_duration: timedelta, + show_buttons: bool = True, +) -> list[Block]: + blocks: list[Block] = [ + slack_helpers.HeaderSectionBlock.new(color_coding_emoji), + SectionBlock( + block_id="content", + fields=[ + MarkdownTextObject(text=f"Requester: <@{requester_slack_id}>"), + MarkdownTextObject(text=f"Group: {group.name} #{group.id}"), + MarkdownTextObject(text=f"Reason: {reason}"), + MarkdownTextObject(text=f"Permission duration: {slack_helpers.humanize_timedelta(permission_duration)}"), + ], + ), + ] + if show_buttons: + blocks.append( + ActionsBlock( + block_id="buttons", + elements=[ + ButtonElement( + action_id=entities.ApproverAction.Approve.value, + text=PlainTextObject(text="Approve"), + style="primary", + value=entities.ApproverAction.Approve.value, + ), + ButtonElement( + action_id=entities.ApproverAction.Discard.value, + text=PlainTextObject(text="Discard"), + style="danger", + value=entities.ApproverAction.Discard.value, + ), + ], + ) + ) + return blocks + +#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- +#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- +#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- +#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- +#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- +#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- + + +def acknowledge_request(ack: Ack): # noqa: ANN201 + ack() + +app.view(RequestForGroupAccessView.CALLBACK_ID)( + ack=acknowledge_request, + lazy=[handle_request_for_group_access_submittion], +) + + +if __name__ == "__main__": + SocketModeHandler(app, creds.app_level_token).start() + From cf565ee3a17aac704a152066e451da6b557842de Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Tue, 27 Aug 2024 11:05:49 +0500 Subject: [PATCH 02/64] feat: allow prividing config by terraform --- slack_handler_lambda.tf | 1 + src/test.py | 8 ++++---- vars.tf | 5 +++++ 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/slack_handler_lambda.tf b/slack_handler_lambda.tf index dc29958..3a1ddc5 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 diff --git a/src/test.py b/src/test.py index 88b5afa..91d9af1 100644 --- a/src/test.py +++ b/src/test.py @@ -38,11 +38,11 @@ import s3 import schedule import slack_helpers +import socket_mode import sso +from access_control import AccessRequestDecision, ApproveRequestDecision from entities import BaseModel from slack_helpers import unhumanize_timedelta -from access_control import AccessRequestDecision, ApproveRequestDecision -import creds # temporary mock #-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- @@ -78,7 +78,7 @@ def wrapper(*args, **kwargs): # noqa: ANN002, ANN003, ANN202 cfg = config.get_config() -app = App(token=creds.bot_token) +app = App(token=socket_mode.bot_token) @@ -741,5 +741,5 @@ def acknowledge_request(ack: Ack): # noqa: ANN201 if __name__ == "__main__": - SocketModeHandler(app, creds.app_level_token).start() + SocketModeHandler(app, socket_mode.app_level_token).start() 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 From 6237f4e632add497922470cefac4aa65c505e0b8 Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Tue, 27 Aug 2024 13:31:15 +0500 Subject: [PATCH 03/64] feat: add a way to approve requests --- src/access_control.py | 44 +++++++++++------- src/test.py | 103 ++++++++++++++++++++---------------------- 2 files changed, 77 insertions(+), 70 deletions(-) diff --git a/src/access_control.py b/src/access_control.py index 5558bb4..f7ea68f 100644 --- a/src/access_control.py +++ b/src/access_control.py @@ -37,23 +37,32 @@ class AccessRequestDecision(BaseModel): approvers: FrozenSet[str] = frozenset() -def make_decision_on_access_request( # noqa: PLR0911 +def determine_affected_statements( statements: FrozenSet[Statement] | FrozenSet[GroupStatement], - requester_email: str, - permission_set_name: str | None = None, account_id: str | None = None, + permission_set_name: str | None = None, group_id: str | None = None, -) -> AccessRequestDecision: +) -> FrozenSet[Statement] | FrozenSet[GroupStatement]: if isinstance(statements, FrozenSet) and all(isinstance(item, Statement) for item in statements): - affected_statements = get_affected_statements(statements, account_id, permission_set_name) #type: ignore # noqa: PGH003 + 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): - affected_statements = get_affected_group_statements(statements, group_id) #type: ignore # noqa: PGH003 + 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" - else: - raise TypeError("Statements contain mixed or unsupported types.") + raise TypeError("Statements contain mixed or unsupported types.") + + +def make_decision_on_access_request( # noqa: PLR0911 + 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 = determine_affected_statements(statements, account_id, permission_set_name, group_id) decision_based_on_statements: set[Statement] | set[GroupStatement] = set() potential_approvers = set() @@ -68,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: @@ -112,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: @@ -132,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 ) diff --git a/src/test.py b/src/test.py index 91d9af1..33271af 100644 --- a/src/test.py +++ b/src/test.py @@ -216,7 +216,7 @@ def handle_request_for_group_access_submittion( group_id=request.group_id, ) - show_buttons = False # TODO: implement this + show_buttons = bool(decision.approvers) slack_response = client.chat_postMessage( blocks=build_approval_request_message_blocks( requester_slack_id=request.requester_slack_id, @@ -308,7 +308,7 @@ def handle_group_button_click(body: dict, client: WebClient, context: BoltContex payload = ButtonGroupClickedPayload.parse_obj(body) logger.info("Button click payload", extra={"payload": payload}) approver = slack_helpers.get_user(client, id=payload.approver_slack_id) - slack_helpers.get_user(client, id=payload.request.requester_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 @@ -347,54 +347,54 @@ def handle_group_button_click(body: dict, client: WebClient, context: BoltContex thread_ts=payload.thread_ts, ) - # decision = access_control.make_decision_on_approve_request( - # action=payload.action, - # statements=cfg.statements, - # account_id=payload.request.account_id, - # permission_set_name=payload.request.permission_set_name, - # 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( - # decision=decision, - # permission_set_name=payload.request.permission_set_name, - # account_id=payload.request.account_id, - # permission_duration=payload.request.permission_duration, - # approver=approver, - # requester=requester, - # reason=payload.request.reason, - # ) - # 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.statements, + 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, + ) + + execute_decision( + decision=decision, + group = 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, + ) + cache_for_dublicate_requests.clear() + return client.chat_postMessage( + channel=payload.channel_id, + text=text, + thread_ts=payload.thread_ts, + ) @@ -678,9 +678,6 @@ def find_in_fields(fields: list[dict[str, str]], key: str) -> str: raise ValueError(f"Failed to parse message. Could not find {key} in fields: {fields}") - - - def build_approval_request_message_blocks( # noqa: PLR0913 requester_slack_id: str, group: entities.aws.SSOGroup, From c6f7351e0fa01d6d860da58fe9b0525e46a3bd22 Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Wed, 28 Aug 2024 14:35:33 +0500 Subject: [PATCH 04/64] refactor: more handle errors func to the errors.py --- src/errors.py | 39 +++++++++++++++++++++++++++++++++++++++ src/main.py | 29 +---------------------------- src/test.py | 34 +--------------------------------- 3 files changed, 41 insertions(+), 61 deletions(-) diff --git a/src/errors.py b/src/errors.py index b06b562..f7f5364 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,33 @@ 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/main.py b/src/main.py index be11a5e..94d5932 100644 --- a/src/main.py +++ b/src/main.py @@ -1,8 +1,6 @@ -import functools 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,7 +9,7 @@ import access_control import config import entities -import errors +from errors import handle_errors import organizations import schedule import slack_helpers @@ -37,31 +35,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. diff --git a/src/test.py b/src/test.py index 33271af..3fe91f0 100644 --- a/src/test.py +++ b/src/test.py @@ -1,11 +1,9 @@ import datetime -import functools import json from datetime import datetime, timedelta import boto3 import jmespath as jp -from aws_lambda_powertools import Logger from mypy_boto3_identitystore import IdentityStoreClient from mypy_boto3_scheduler import EventBridgeSchedulerClient from mypy_boto3_scheduler import type_defs as scheduler_type_defs @@ -33,7 +31,6 @@ import access_control import config import entities -import errors import events import s3 import schedule @@ -42,6 +39,7 @@ import sso from access_control import AccessRequestDecision, ApproveRequestDecision from entities import BaseModel +from errors import handle_errors from slack_helpers import unhumanize_timedelta # temporary mock @@ -51,31 +49,6 @@ logger = config.get_logger(service="main") -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." - 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 - - cfg = config.get_config() app = App(token=socket_mode.bot_token) @@ -119,7 +92,6 @@ def get_all_groups(identity_store_id, identity_store_client: IdentityStoreClient logger.debug("Groups", extra={"groups": groups}) return groups -@handle_errors def add_user_to_a_group(sso_group_id, sso_user_id, identity_store_id, identity_store_client:IdentityStoreClient): # noqa: ANN201 ANN001 responce = identity_store_client.create_group_membership( GroupId=sso_group_id, @@ -129,13 +101,11 @@ def add_user_to_a_group(sso_group_id, sso_user_id, identity_store_id, identity_s logger.info("User added to the group", extra={"group_id": sso_group_id, "user_id": sso_user_id, }) return responce -@handle_errors def remove_user_from_group(identity_store_id, membership_id, identity_store_client: IdentityStoreClient): # noqa: ANN201 ANN001 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}) return responce -@handle_errors def is_user_in_group(identity_store_id: str, group_id: str, sso_user_id: str, identity_store_client: IdentityStoreClient) -> str | None: paginator = identity_store_client.get_paginator("list_group_memberships") for page in paginator.paginate(IdentityStoreId=identity_store_id, GroupId=group_id): @@ -194,8 +164,6 @@ def load_select_options(client: WebClient, body: dict) -> SlackResponse: - - @handle_errors def handle_request_for_group_access_submittion( body: dict, From 90845546ec93fff96f24150e5932fcd5012fb14d Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Wed, 28 Aug 2024 14:50:20 +0500 Subject: [PATCH 05/64] refactoring: move sso to the sso.py & add more type hints --- src/sso.py | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++- src/test.py | 71 +++----------------------------------------------- 2 files changed, 77 insertions(+), 68 deletions(-) diff --git a/src/sso.py b/src/sso.py index 39c4bd8..9c31764 100644 --- a/src/sso.py +++ b/src/sso.py @@ -3,7 +3,7 @@ import datetime 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 +12,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 @@ -352,3 +353,74 @@ def get_account_assignment_information( [ps.arn for ps in permission_sets], ) return account_assignments + + +#-----------------Group Assignments-----------------# + +def get_all_groups(identity_store_id: str, identity_store_client: IdentityStoreClient) -> list[entities.aws.SSOGroup]: + + 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") + ) + logger.info("Got information about all groups.") + logger.debug("Groups", extra={"groups": groups}) + return groups + except Exception as e: + logger.error("Error while getting information about all groups", 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 is_user_in_group(identity_store_id: str, group_id: str, sso_user_id: str, identity_store_client: IdentityStoreClient) -> str | None: + paginator = identity_store_client.get_paginator("list_group_memberships") + for page in paginator.paginate(IdentityStoreId=identity_store_id, GroupId=group_id): + for group in page["GroupMemberships"]: + try: + if group["MemberId"]["UserId"] == sso_user_id: # type: ignore # noqa: PGH003 + logger.info("User is in the group", extra={"group": group}) + return group["MembershipId"] # type: ignore # noqa: PGH003 (ignoring this because we checked if user is in the group) + except Exception as e: + logger.error("Error while checking if user is in the group", extra={"error": e}) + 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"), + ) + +#-----------------Group Assignments-----------------# diff --git a/src/test.py b/src/test.py index 3fe91f0..de69fe6 100644 --- a/src/test.py +++ b/src/test.py @@ -42,14 +42,7 @@ from errors import handle_errors from slack_helpers import unhumanize_timedelta -# temporary mock -#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- -#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- -#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- - logger = config.get_logger(service="main") - - cfg = config.get_config() app = App(token=socket_mode.bot_token) @@ -72,62 +65,6 @@ identity_store_id = sso_instance.identity_store_id - -@handle_errors -def get_all_groups(identity_store_id, identity_store_client: IdentityStoreClient) -> list[entities.aws.SSOGroup]: # noqa: ANN102 ANN001 - 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") - ) - # TODO: handle case when there are no groups - logger.info("Got information about all groups.") - logger.debug("Groups", extra={"groups": groups}) - return groups - -def add_user_to_a_group(sso_group_id, sso_user_id, identity_store_id, identity_store_client:IdentityStoreClient): # noqa: ANN201 ANN001 - 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, membership_id, identity_store_client: IdentityStoreClient): # noqa: ANN201 ANN001 - 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}) - return responce - -def is_user_in_group(identity_store_id: str, group_id: str, sso_user_id: str, identity_store_client: IdentityStoreClient) -> str | None: - paginator = identity_store_client.get_paginator("list_group_memberships") - for page in paginator.paginate(IdentityStoreId=identity_store_id, GroupId=group_id): - for group in page["GroupMemberships"]: - try: - if group["MemberId"]["UserId"] == sso_user_id: # type: ignore # noqa: PGH003 - logger.info("User is in the group", extra={"group": group}) - return group["MembershipId"] # type: ignore # noqa: PGH003 (ignoring this because we checked if user is in the group) - except Exception as e: - logger.error("Error while checking if user is in the group", extra={"error": e}) - return None - -def describe_group(identity_store_id, group_id, identity_store_client: IdentityStoreClient) -> entities.aws.SSOGroup: # noqa: ANN201 ANN001 - 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"), - ) - #-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- #-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- #-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- @@ -150,7 +87,7 @@ def show_initial_form(client: WebClient, body: dict, ack: Ack) -> SlackResponse @handle_errors def load_select_options(client: WebClient, body: dict) -> SlackResponse: - groups = get_all_groups(identity_store_id, identity_store_client) + groups = sso.get_all_groups(identity_store_id, identity_store_client) trigger_id = body["trigger_id"] @@ -176,7 +113,7 @@ def handle_request_for_group_access_submittion( logger.info("View submitted", extra={"view": request}) requester = slack_helpers.get_user(client, id=request.requester_slack_id) - group = describe_group(identity_store_id, request.group_id, identity_store_client) + group = sso.describe_group(identity_store_id, request.group_id, identity_store_client) decision = access_control.make_decision_on_access_request( cfg.group_statements, @@ -350,7 +287,7 @@ def handle_group_button_click(body: dict, client: WebClient, context: BoltContex execute_decision( decision=decision, - group = describe_group(identity_store_id, payload.request.group_id, identity_store_client), + 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, @@ -394,7 +331,7 @@ def execute_decision( # noqa: PLR0913 return False # Temporary solution for testing - responce = add_user_to_a_group(group.id, user_principal_id, identity_store_id, identity_store_client) + responce = sso.add_user_to_a_group(group.id, user_principal_id, identity_store_id, identity_store_client) logger.info("User added to the group", extra={"group_id": group.id, "user_id": user_principal_id, }) s3.log_operation( From 990a20b751dc58fec50b1591ef46b13f48e34bc9 Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Wed, 28 Aug 2024 15:37:37 +0500 Subject: [PATCH 06/64] fix: import func from sso --- src/revoker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/revoker.py b/src/revoker.py index 9dce588..1a824db 100644 --- a/src/revoker.py +++ b/src/revoker.py @@ -265,7 +265,7 @@ def handle_scheduled_group_assignment_deletion( # noqa: PLR0913 ) -> SlackResponse | None: logger.info("Handling scheduled account assignment deletion", extra={"revoke_event": GroupRevokeEvent}) group_assignment = group_revoke_event.group_assignment - test.remove_user_from_group(group_assignment.identity_store_id, group_assignment.membership_id, identitystore_client) + sso.remove_user_from_group(group_assignment.identity_store_id, group_assignment.membership_id, identitystore_client) # TODO: Add logging s3.log_operation( audit_entry=s3.GroupAccessAuditEntry( From d3928eb5cbf2001eac14554b96294f9af4de7fe7 Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Wed, 28 Aug 2024 15:39:26 +0500 Subject: [PATCH 07/64] refactoring: use one "show initial form func, and move request handling to main.py --- src/main.py | 33 +++++++++++++++++++++++++-------- src/slack_helpers.py | 3 ++- src/test.py | 23 +---------------------- 3 files changed, 28 insertions(+), 31 deletions(-) diff --git a/src/main.py b/src/main.py index 94d5932..98e5d57 100644 --- a/src/main.py +++ b/src/main.py @@ -22,6 +22,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( @@ -42,17 +43,33 @@ def lambda_handler(event: str, context): # noqa: ANN001, ANN201 # 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: +@handle_errors +def show_initial_form_for_request(client: WebClient, + body: dict, + ack: Ack, + view_class: slack_helpers.RequestForAccessView | test.RequestForGroupAccessView +) -> SlackResponse: ack() - logger.info("Showing initial form") + 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=slack_helpers.RequestForAccessView.build()) + 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 -def load_select_options(client: WebClient, body: dict) -> SlackResponse: +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_all_groups(sso_instance.identity_store_id, identity_store_client) + trigger_id = body["trigger_id"] + + view = test.RequestForGroupAccessView.update_with_groups(groups=groups) + return client.views_update(view_id=trigger_view_map[trigger_id], view=view) + + +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}) @@ -65,13 +82,13 @@ def load_select_options(client: WebClient, body: dict) -> SlackResponse: app.shortcut("request_for_access")( - show_initial_form, - load_select_options, + show_initial_form_for_request, + load_select_options_for_account_access_request ) app.shortcut("request_for_group_membership")( - test.show_initial_form, - test.load_select_options, + show_initial_form_for_request, + load_select_options_for_group_access_request ) cache_for_dublicate_requests = {} diff --git a/src/slack_helpers.py b/src/slack_helpers.py index b5d23ef..e78450b 100644 --- a/src/slack_helpers.py +++ b/src/slack_helpers.py @@ -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" diff --git a/src/test.py b/src/test.py index de69fe6..cd0af4e 100644 --- a/src/test.py +++ b/src/test.py @@ -74,30 +74,8 @@ #Main trigger_view_map = {} -@handle_errors -def show_initial_form(client: WebClient, body: dict, ack: Ack) -> SlackResponse | None: - ack() - logger.info("Showing initial form for group access") - logger.debug("Request body", extra={"body": body}) - trigger_id = body["trigger_id"] - response = client.views_open(trigger_id=trigger_id, view=RequestForGroupAccessView.build()) - trigger_view_map[trigger_id] = response.data["view"]["id"] # type: ignore # noqa: PGH003 - return response -@handle_errors -def load_select_options(client: WebClient, body: dict) -> SlackResponse: - groups = sso.get_all_groups(identity_store_id, identity_store_client) - - trigger_id = body["trigger_id"] - - view = RequestForGroupAccessView.update_with_groups(groups=groups) - return client.views_update(view_id=trigger_view_map[trigger_id], view=view) - -app.shortcut("request_for_group_membership")( - show_initial_form, - load_select_options, -) @@ -434,6 +412,7 @@ class RequestForGroupAccess(entities.BaseModel): permission_duration: timedelta class RequestForGroupAccessView: + __name__ = "RequestForGroupAccessView" CALLBACK_ID = "request_for_group_access_submitted" REASON_BLOCK_ID = "provide_reason" From bbca552f07a73b248d2807407b9af0c4fbb1b8a7 Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Thu, 29 Aug 2024 12:32:25 +0500 Subject: [PATCH 08/64] cleanup: rm unused --- src/test.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/test.py b/src/test.py index cd0af4e..09bdf44 100644 --- a/src/test.py +++ b/src/test.py @@ -65,17 +65,6 @@ identity_store_id = sso_instance.identity_store_id -#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- -#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- -#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- -#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- -#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- -#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- -#Main - -trigger_view_map = {} - - From bd5d73db0847f9a4a41cf80082f25380f880d075 Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Thu, 29 Aug 2024 12:40:25 +0500 Subject: [PATCH 09/64] feat: refactor build_approval_request_message_blocks to work with both group's and account access --- src/slack_helpers.py | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/src/slack_helpers.py b/src/slack_helpers.py index e78450b..874eeee 100644 --- a/src/slack_helpers.py +++ b/src/slack_helpers.py @@ -223,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( From b63aad1c6da216b88d214c5c178609b224731c95 Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Thu, 29 Aug 2024 12:42:05 +0500 Subject: [PATCH 10/64] feat: use build_initial_form_handler to show initial form fo both types of requests --- src/main.py | 40 ++++++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/src/main.py b/src/main.py index 98e5d57..03315b6 100644 --- a/src/main.py +++ b/src/main.py @@ -1,3 +1,4 @@ +import test from datetime import timedelta import boto3 @@ -9,12 +10,13 @@ import access_control import config import entities -from errors import handle_errors import organizations import schedule import slack_helpers import sso -import test +from errors import handle_errors +from typing import Callable + logger = config.get_logger(service="main") @@ -43,20 +45,22 @@ def lambda_handler(event: str, context): # noqa: ANN001, ANN201 # and available in both functions, we can use it as a key. The value is the view_id. -@handle_errors -def show_initial_form_for_request(client: WebClient, - body: dict, - ack: Ack, - view_class: slack_helpers.RequestForAccessView | test.RequestForGroupAccessView -) -> 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 - +def build_initial_form_handler( + view_class: slack_helpers.RequestForAccessView | + test.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)") @@ -82,12 +86,12 @@ def load_select_options_for_account_access_request(client: WebClient, body: dict app.shortcut("request_for_access")( - show_initial_form_for_request, + 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")( - show_initial_form_for_request, + build_initial_form_handler(view_class=test.RequestForGroupAccessView), #type: ignore # noqa: PGH003 load_select_options_for_group_access_request ) From 46c999a877334d0d14c0da01725d8bb4fa7621c6 Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Thu, 29 Aug 2024 12:43:05 +0500 Subject: [PATCH 11/64] fix: import build_approval_request_message_blocks from slack_helpers & rm unused --- src/test.py | 45 +-------------------------------------------- 1 file changed, 1 insertion(+), 44 deletions(-) diff --git a/src/test.py b/src/test.py index 09bdf44..aa8ac06 100644 --- a/src/test.py +++ b/src/test.py @@ -90,7 +90,7 @@ def handle_request_for_group_access_submittion( show_buttons = bool(decision.approvers) slack_response = client.chat_postMessage( - blocks=build_approval_request_message_blocks( + blocks=slack_helpers.build_approval_request_message_blocks( requester_slack_id=request.requester_slack_id, group=group, reason=request.reason, @@ -550,49 +550,6 @@ def find_in_fields(fields: list[dict[str, str]], key: str) -> str: return field["text"].split(": ")[1].strip() raise ValueError(f"Failed to parse message. Could not find {key} in fields: {fields}") - -def build_approval_request_message_blocks( # noqa: PLR0913 - requester_slack_id: str, - group: entities.aws.SSOGroup, - reason: str, - color_coding_emoji: str, - permission_duration: timedelta, - show_buttons: bool = True, -) -> list[Block]: - blocks: list[Block] = [ - slack_helpers.HeaderSectionBlock.new(color_coding_emoji), - SectionBlock( - block_id="content", - fields=[ - MarkdownTextObject(text=f"Requester: <@{requester_slack_id}>"), - MarkdownTextObject(text=f"Group: {group.name} #{group.id}"), - MarkdownTextObject(text=f"Reason: {reason}"), - MarkdownTextObject(text=f"Permission duration: {slack_helpers.humanize_timedelta(permission_duration)}"), - ], - ), - ] - if show_buttons: - blocks.append( - ActionsBlock( - block_id="buttons", - elements=[ - ButtonElement( - action_id=entities.ApproverAction.Approve.value, - text=PlainTextObject(text="Approve"), - style="primary", - value=entities.ApproverAction.Approve.value, - ), - ButtonElement( - action_id=entities.ApproverAction.Discard.value, - text=PlainTextObject(text="Discard"), - style="danger", - value=entities.ApproverAction.Discard.value, - ), - ], - ) - ) - return blocks - #-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- #-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- #-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- From ef93af1d07f6d03f07cf6d72ac4d71fab7f1d2ee Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Thu, 29 Aug 2024 12:50:53 +0500 Subject: [PATCH 12/64] fmt: rm unused, refactor & more logs --- src/revoker.py | 3 +-- src/sso.py | 4 +--- src/test.py | 5 +---- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/src/revoker.py b/src/revoker.py index 1a824db..95fb875 100644 --- a/src/revoker.py +++ b/src/revoker.py @@ -263,10 +263,9 @@ def handle_scheduled_group_assignment_deletion( # noqa: PLR0913 slack_client: slack_sdk.WebClient, identitystore_client: IdentityStoreClient, ) -> SlackResponse | None: - logger.info("Handling scheduled account assignment deletion", extra={"revoke_event": GroupRevokeEvent}) + logger.info("Handling scheduled group access revokation", extra={"revoke_event": GroupRevokeEvent}) group_assignment = group_revoke_event.group_assignment sso.remove_user_from_group(group_assignment.identity_store_id, group_assignment.membership_id, identitystore_client) - # TODO: Add logging s3.log_operation( audit_entry=s3.GroupAccessAuditEntry( group_name = group_assignment.group_name, diff --git a/src/sso.py b/src/sso.py index 9c31764..f26aa5d 100644 --- a/src/sso.py +++ b/src/sso.py @@ -343,16 +343,14 @@ 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) # TODO: Remove this line 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( # TODO: move to the return block + 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-----------------# diff --git a/src/test.py b/src/test.py index aa8ac06..50525af 100644 --- a/src/test.py +++ b/src/test.py @@ -8,14 +8,11 @@ from mypy_boto3_scheduler import EventBridgeSchedulerClient from mypy_boto3_scheduler import type_defs as scheduler_type_defs from mypy_boto3_sso_admin import SSOAdminClient -from pydantic import BaseModel, root_validator +from pydantic import root_validator from slack_bolt import Ack, App, BoltContext from slack_bolt.adapter.socket_mode import SocketModeHandler from slack_sdk import WebClient from slack_sdk.models.blocks import ( - ActionsBlock, - Block, - ButtonElement, DividerBlock, InputBlock, MarkdownTextObject, From 09630ff87c7f06e169671a679b2007e74a125bd8 Mon Sep 17 00:00:00 2001 From: Anton <125114167+EreminAnton@users.noreply.github.com> Date: Fri, 30 Aug 2024 13:53:30 +0500 Subject: [PATCH 13/64] fix: run codeguru only in main branch commits --- .github/workflows/code-guru.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/code-guru.yml b/.github/workflows/code-guru.yml index 0974329..53012fa 100644 --- a/.github/workflows/code-guru.yml +++ b/.github/workflows/code-guru.yml @@ -2,7 +2,7 @@ name: CodeGuru Review on: push: - branches: [ "*" ] + branches: [ "main" ] workflow_dispatch: permissions: From 0822a7c4ac81fc33d71b03e434723481f58e5812 Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Thu, 29 Aug 2024 12:56:16 +0500 Subject: [PATCH 14/64] refactor: move schedule creation to the schedule.py --- src/schedule.py | 38 +++++++++++++++++++++++++++++++++ src/test.py | 56 ++----------------------------------------------- 2 files changed, 40 insertions(+), 54 deletions(-) diff --git a/src/schedule.py b/src/schedule.py index 72a8363..e0a4b9b 100644 --- a/src/schedule.py +++ b/src/schedule.py @@ -152,6 +152,44 @@ 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().strftime("%Y-%m-%d-%H-%M-%S") + revoke_event = GroupRevokeEvent( + action="event_bridge_group_revoke", + 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, revoke_event) + 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, diff --git a/src/test.py b/src/test.py index 50525af..9f901c5 100644 --- a/src/test.py +++ b/src/test.py @@ -1,6 +1,4 @@ -import datetime -import json -from datetime import datetime, timedelta +from datetime import timedelta import boto3 import jmespath as jp @@ -315,7 +313,7 @@ def execute_decision( # noqa: PLR0913 ), ) - schedule_group_revoke_event( + schedule.schedule_group_revoke_event( permission_duration=permission_duration, schedule_client=schedule_client, approver=approver, @@ -331,56 +329,6 @@ def execute_decision( # noqa: PLR0913 return# type: ignore # noqa: PGH003 - - -#Schedule -#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- -#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- -#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- -#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- -#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- -#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- - - - -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().strftime("%Y-%m-%d-%H-%M-%S") - revoke_event = events.GroupRevokeEvent( - action="event_bridge_group_revoke", - schedule_name=schedule_name, - approver=approver, - requester=requester, - group_assignment= group_assignment, - permission_duration=permission_duration, - ) - schedule.get_and_delete_scheduled_revoke_event_if_already_exist(schedule_client, revoke_event) - 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=schedule.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(), - }, - ), - ), - ) - - #Slack #-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- #-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- From eca56e20edce41ec118711d043bf9b86b6f7afa8 Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Thu, 29 Aug 2024 12:56:48 +0500 Subject: [PATCH 15/64] fmt: rm unused --- src/test.py | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/src/test.py b/src/test.py index 9f901c5..210badc 100644 --- a/src/test.py +++ b/src/test.py @@ -3,12 +3,9 @@ import boto3 import jmespath as jp from mypy_boto3_identitystore import IdentityStoreClient -from mypy_boto3_scheduler import EventBridgeSchedulerClient -from mypy_boto3_scheduler import type_defs as scheduler_type_defs from mypy_boto3_sso_admin import SSOAdminClient from pydantic import root_validator from slack_bolt import Ack, App, BoltContext -from slack_bolt.adapter.socket_mode import SocketModeHandler from slack_sdk import WebClient from slack_sdk.models.blocks import ( DividerBlock, @@ -26,7 +23,6 @@ import access_control import config import entities -import events import s3 import schedule import slack_helpers @@ -275,9 +271,6 @@ def handle_group_button_click(body: dict, client: WebClient, context: BoltContex #-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- #-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- - - - def execute_decision( # noqa: PLR0913 decision: AccessRequestDecision | ApproveRequestDecision, group: entities.aws.SSOGroup, @@ -494,24 +487,3 @@ def find_in_fields(fields: list[dict[str, str]], key: str) -> str: 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}") - -#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- -#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- -#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- -#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- -#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- -#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- - - -def acknowledge_request(ack: Ack): # noqa: ANN201 - ack() - -app.view(RequestForGroupAccessView.CALLBACK_ID)( - ack=acknowledge_request, - lazy=[handle_request_for_group_access_submittion], -) - - -if __name__ == "__main__": - SocketModeHandler(app, socket_mode.app_level_token).start() - From 398c1e3c1f290e9b2ddcfa0e39a2de1112b1f244 Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Fri, 30 Aug 2024 13:54:38 +0500 Subject: [PATCH 16/64] feat: rename execute_decision to execute_decision_on_group_request --- src/test.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/test.py b/src/test.py index 210badc..8df7538 100644 --- a/src/test.py +++ b/src/test.py @@ -144,7 +144,7 @@ def handle_request_for_group_access_submittion( user_principal_id = sso.get_user_principal_id_by_email(identity_store_client, sso_instance.identity_store_id, requester.email) - execute_decision( + execute_decision_on_group_request( group = group, user_principal_id = user_principal_id, permission_duration = request.permission_duration, @@ -243,7 +243,7 @@ def handle_group_button_click(body: dict, client: WebClient, context: BoltContex text=text, ) - execute_decision( + 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), @@ -271,7 +271,7 @@ def handle_group_button_click(body: dict, client: WebClient, context: BoltContex #-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- #-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- -def execute_decision( # noqa: PLR0913 +def execute_decision_on_group_request( # noqa: PLR0913 decision: AccessRequestDecision | ApproveRequestDecision, group: entities.aws.SSOGroup, user_principal_id: str, @@ -297,8 +297,8 @@ def execute_decision( # noqa: PLR0913 reason = reason, requester_slack_id = requester.id, requester_email = requester.email, - approver_slack_id = "N/A", - approver_email = "N/A", + approver_slack_id = "NA", + approver_email = "NA", operation_type = "grant", permission_duration = permission_duration, audit_entry_type = "group", From 71953546be2a1621a4e501358b323580365c0998 Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Fri, 30 Aug 2024 15:43:51 +0500 Subject: [PATCH 17/64] feat: handle if user is already in the group --- src/test.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/test.py b/src/test.py index 8df7538..b972d03 100644 --- a/src/test.py +++ b/src/test.py @@ -212,7 +212,7 @@ def handle_group_button_click(body: dict, client: WebClient, context: BoltContex decision = access_control.make_decision_on_approve_request( action=payload.action, - statements=cfg.statements, + statements=cfg.group_statements, #type: ignore # noqa: PGH003 group_id=payload.request.group_id, approver_email=approver.email, requester_email=requester.email, @@ -285,8 +285,15 @@ def execute_decision_on_group_request( # noqa: PLR0913 logger.info("Access request denied") return False # Temporary solution for testing + if not sso.is_user_in_group( + identity_store_id = identity_store_id, + group_id = group.id, + sso_user_id = user_principal_id, + identity_store_client = identity_store_client, + ): + + responce = sso.add_user_to_a_group(group.id, user_principal_id, identity_store_id, identity_store_client) - responce = sso.add_user_to_a_group(group.id, user_principal_id, identity_store_id, identity_store_client) logger.info("User added to the group", extra={"group_id": group.id, "user_id": user_principal_id, }) s3.log_operation( From ca7233328e90a6ee2d7d5a5694a6cd8f7fd4d979 Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Fri, 30 Aug 2024 16:08:42 +0500 Subject: [PATCH 18/64] feat: more refactorings & rename test.py to group.py --- src/access_control.py | 61 ++++++ src/group.py | 230 ++++++++++++++++++++ src/main.py | 14 +- src/revoker.py | 1 - src/slack_helpers.py | 165 ++++++++++++++ src/test.py | 496 ------------------------------------------ 6 files changed, 463 insertions(+), 504 deletions(-) create mode 100644 src/group.py delete mode 100644 src/test.py diff --git a/src/access_control.py b/src/access_control.py index f7ea68f..fae0fd6 100644 --- a/src/access_control.py +++ b/src/access_control.py @@ -211,3 +211,64 @@ 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 not 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, + ): + + responce = sso.add_user_to_a_group(group.id, user_principal_id, identity_store_id, identitystore_client) + + logger.info("User added to the group", extra={"group_id": group.id, "user_id": user_principal_id, }) + + s3.log_operation( + audit_entry=s3.GroupAccessAuditEntry( + group_name = group.name, + group_id = group.id, + membership_id = responce["MembershipId"], + reason = reason, + requester_slack_id = requester.id, + requester_email = requester.email, + approver_slack_id = "NA", + approver_email = "NA", + operation_type = "grant", + permission_duration = permission_duration, + audit_entry_type = "group", + 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=responce["MembershipId"], + ), + ) + return# type: ignore # noqa: PGH003 diff --git a/src/group.py b/src/group.py new file mode 100644 index 0000000..b17df8d --- /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 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() + + +identity_store_client: IdentityStoreClient = boto3.client("identitystore", region_name="us-east-1") +sso_client: SSOAdminClient = boto3.client("sso-admin", region_name="us-east-1") +schedule_client = boto3.client("scheduler", region_name="us-east-1") +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 03315b6..55eb12b 100644 --- a/src/main.py +++ b/src/main.py @@ -1,4 +1,4 @@ -import test +import group from datetime import timedelta import boto3 @@ -47,7 +47,7 @@ def lambda_handler(event: str, context): # noqa: ANN001, ANN201 def build_initial_form_handler( view_class: slack_helpers.RequestForAccessView | - test.RequestForGroupAccessView + slack_helpers.RequestForGroupAccessView ) -> Callable[[WebClient, dict, Ack], SlackResponse]: def show_initial_form_for_request(client: WebClient, body: dict, @@ -69,7 +69,7 @@ def load_select_options_for_group_access_request(client: WebClient, body: dict) groups = sso.get_all_groups(sso_instance.identity_store_id, identity_store_client) trigger_id = body["trigger_id"] - view = test.RequestForGroupAccessView.update_with_groups(groups=groups) + view = slack_helpers.RequestForGroupAccessView.update_with_groups(groups=groups) return client.views_update(view_id=trigger_view_map[trigger_id], view=view) @@ -91,7 +91,7 @@ def load_select_options_for_account_access_request(client: WebClient, body: dict ) app.shortcut("request_for_group_membership")( - build_initial_form_handler(view_class=test.RequestForGroupAccessView), #type: ignore # noqa: PGH003 + build_initial_form_handler(view_class=group.RequestForGroupAccessView), #type: ignore # noqa: PGH003 load_select_options_for_group_access_request ) @@ -105,7 +105,7 @@ def handle_button_click(body: dict, client: WebClient, context: BoltContext) -> payload = slack_helpers.ButtonClickedPayload.parse_obj(body) except Exception as e: logger.exception(e) - return test.handle_group_button_click(body, client, context) + 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) @@ -322,9 +322,9 @@ def handle_request_for_access_submittion( lazy=[handle_request_for_access_submittion], ) -app.view(test.RequestForGroupAccessView.CALLBACK_ID)( +app.view(slack_helpers.RequestForGroupAccessView.CALLBACK_ID)( ack=acknowledge_request, - lazy=[test.handle_request_for_group_access_submittion], + lazy=[group.handle_request_for_group_access_submittion], ) diff --git a/src/revoker.py b/src/revoker.py index 95fb875..ecdb94d 100644 --- a/src/revoker.py +++ b/src/revoker.py @@ -28,7 +28,6 @@ ScheduledGroupRevokeEvent, GroupRevokeEvent ) -import test logger = config.get_logger(service="revoker") diff --git a/src/slack_helpers.py b/src/slack_helpers.py index 874eeee..d67308a 100644 --- a/src/slack_helpers.py +++ b/src/slack_helpers.py @@ -422,3 +422,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] # noqa: ANN102 + ) -> View: + 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 + 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": values.get("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/test.py b/src/test.py deleted file mode 100644 index b972d03..0000000 --- a/src/test.py +++ /dev/null @@ -1,496 +0,0 @@ -from datetime import timedelta - -import boto3 -import jmespath as jp -from mypy_boto3_identitystore import IdentityStoreClient -from mypy_boto3_sso_admin import SSOAdminClient -from pydantic import root_validator -from slack_bolt import Ack, App, BoltContext -from slack_sdk import WebClient -from slack_sdk.models.blocks import ( - DividerBlock, - InputBlock, - MarkdownTextObject, - Option, - PlainTextInputElement, - PlainTextObject, - SectionBlock, - StaticSelectElement, -) -from slack_sdk.models.views import View -from slack_sdk.web.slack_response import SlackResponse - -import access_control -import config -import entities -import s3 -import schedule -import slack_helpers -import socket_mode -import sso -from access_control import AccessRequestDecision, ApproveRequestDecision -from entities import BaseModel -from errors import handle_errors -from slack_helpers import unhumanize_timedelta - -logger = config.get_logger(service="main") -cfg = config.get_config() -app = App(token=socket_mode.bot_token) - - - - -# SSO -#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- -#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- -#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- -#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- -#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- -#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- - -identity_store_client: IdentityStoreClient = boto3.client("identitystore", region_name="us-east-1") -sso_client: SSOAdminClient = boto3.client("sso-admin", region_name="us-east-1") -schedule_client = boto3.client("scheduler", region_name="us-east-1") - -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 = 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) - - 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 - ) - - 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 = 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, - ) - - 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, - ) - cache_for_dublicate_requests.clear() - return client.chat_postMessage( - channel=payload.channel_id, - text=text, - thread_ts=payload.thread_ts, - ) - - - - - -# Access control -#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- -#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- -#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- -#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- -#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- -#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- - -def execute_decision_on_group_request( # noqa: PLR0913 - decision: AccessRequestDecision | ApproveRequestDecision, - group: entities.aws.SSOGroup, - user_principal_id: str, - permission_duration: timedelta, - approver: entities.slack.User, - requester: entities.slack.User, - reason: str, -) -> bool: - logger.info("Executing decision") - if not decision.grant: - logger.info("Access request denied") - return False # Temporary solution for testing - - if not sso.is_user_in_group( - identity_store_id = identity_store_id, - group_id = group.id, - sso_user_id = user_principal_id, - identity_store_client = identity_store_client, - ): - - responce = sso.add_user_to_a_group(group.id, user_principal_id, identity_store_id, identity_store_client) - - logger.info("User added to the group", extra={"group_id": group.id, "user_id": user_principal_id, }) - - s3.log_operation( - audit_entry=s3.GroupAccessAuditEntry( - group_name = group.name, - group_id = group.id, - membership_id = responce["MembershipId"], - reason = reason, - requester_slack_id = requester.id, - requester_email = requester.email, - approver_slack_id = "NA", - approver_email = "NA", - operation_type = "grant", - permission_duration = permission_duration, - audit_entry_type = "group", - 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=responce["MembershipId"], - ), - ) - return# type: ignore # noqa: PGH003 - - -#Slack -#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- -#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- -#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- -#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- -#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- -#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- - - - -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=slack_helpers.get_max_duration_block(cfg)[0], - options=slack_helpers.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] # noqa: ANN102 - ) -> View: - view = cls.build() - view.blocks = slack_helpers.remove_blocks(view.blocks, block_ids=[cls.LOADING_BLOCK_ID]) - view.blocks = slack_helpers.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 - 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": values.get("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}") From 9f0af731d43ef9907baffb56c17faf6946d9e754 Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Fri, 30 Aug 2024 16:11:37 +0500 Subject: [PATCH 19/64] feat: use default sesion --- src/group.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/group.py b/src/group.py index b17df8d..3b6436a 100644 --- a/src/group.py +++ b/src/group.py @@ -3,6 +3,7 @@ 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 @@ -19,10 +20,10 @@ logger = config.get_logger(service="main") cfg = config.get_config() - -identity_store_client: IdentityStoreClient = boto3.client("identitystore", region_name="us-east-1") -sso_client: SSOAdminClient = boto3.client("sso-admin", region_name="us-east-1") -schedule_client = boto3.client("scheduler", region_name="us-east-1") +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 From d86bf4f5d37f1f7e40e25fe2c2e39c568f8ce685 Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Mon, 2 Sep 2024 11:52:54 +0500 Subject: [PATCH 20/64] fix: NA if response is unbound --- src/access_control.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/access_control.py b/src/access_control.py index fae0fd6..2912ebe 100644 --- a/src/access_control.py +++ b/src/access_control.py @@ -237,7 +237,7 @@ def execute_decision_on_group_request( # noqa: PLR0913 identity_store_client = identitystore_client, ): - responce = sso.add_user_to_a_group(group.id, user_principal_id, identity_store_id, identitystore_client) + response = sso.add_user_to_a_group(group.id, user_principal_id, identity_store_id, identitystore_client) logger.info("User added to the group", extra={"group_id": group.id, "user_id": user_principal_id, }) @@ -245,7 +245,7 @@ def execute_decision_on_group_request( # noqa: PLR0913 audit_entry=s3.GroupAccessAuditEntry( group_name = group.name, group_id = group.id, - membership_id = responce["MembershipId"], + membership_id = response["MembershipId"] if response else "NA", reason = reason, requester_slack_id = requester.id, requester_email = requester.email, @@ -268,7 +268,7 @@ def execute_decision_on_group_request( # noqa: PLR0913 group_name=group.name, group_id=group.id, user_principal_id=user_principal_id, - membership_id=responce["MembershipId"], + membership_id=response["MembershipId"] if response else "NA", ), ) return# type: ignore # noqa: PGH003 From 1dfb02c0b416df6405e88fd171f61b2777b2eff6 Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Mon, 2 Sep 2024 11:58:58 +0500 Subject: [PATCH 21/64] fix: import RequestForGroupAccessView from slack_helpers --- src/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.py b/src/main.py index 55eb12b..f77cd79 100644 --- a/src/main.py +++ b/src/main.py @@ -91,7 +91,7 @@ def load_select_options_for_account_access_request(client: WebClient, body: dict ) app.shortcut("request_for_group_membership")( - build_initial_form_handler(view_class=group.RequestForGroupAccessView), #type: ignore # noqa: PGH003 + build_initial_form_handler(view_class=slack_helpers.RequestForGroupAccessView), #type: ignore # noqa: PGH003 load_select_options_for_group_access_request ) From 8c37c378a2a02e82ec918f7f4b593c007d699ca5 Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Mon, 2 Sep 2024 12:02:53 +0500 Subject: [PATCH 22/64] fix: create response anyway, and use get to get value --- src/access_control.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/access_control.py b/src/access_control.py index 2912ebe..59294dd 100644 --- a/src/access_control.py +++ b/src/access_control.py @@ -230,6 +230,7 @@ def execute_decision_on_group_request( # noqa: PLR0913 logger.info("Access request denied") return False # Temporary solution for testing + response = {} if not sso.is_user_in_group( identity_store_id = identity_store_id, group_id = group.id, @@ -245,7 +246,7 @@ def execute_decision_on_group_request( # noqa: PLR0913 audit_entry=s3.GroupAccessAuditEntry( group_name = group.name, group_id = group.id, - membership_id = response["MembershipId"] if response else "NA", + membership_id = response.get("MembershipId", "NA"), reason = reason, requester_slack_id = requester.id, requester_email = requester.email, @@ -268,7 +269,7 @@ def execute_decision_on_group_request( # noqa: PLR0913 group_name=group.name, group_id=group.id, user_principal_id=user_principal_id, - membership_id=response["MembershipId"] if response else "NA", + membership_id=response.get("MembershipId", "NA") ), ) return# type: ignore # noqa: PGH003 From fe0c32d6372b870c452ce310d3586c33b8b40d68 Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Mon, 2 Sep 2024 12:20:52 +0500 Subject: [PATCH 23/64] feat: handle execute_decision_on_group_request logs better --- src/access_control.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/access_control.py b/src/access_control.py index 59294dd..3c833d0 100644 --- a/src/access_control.py +++ b/src/access_control.py @@ -231,16 +231,17 @@ def execute_decision_on_group_request( # noqa: PLR0913 return False # Temporary solution for testing response = {} - if not 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, - ): - + if 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}) + + else: response = sso.add_user_to_a_group(group.id, user_principal_id, identity_store_id, identitystore_client) - - logger.info("User added to the group", extra={"group_id": group.id, "user_id": user_principal_id, }) + logger.info("User added to the group",extra={"group_id": group.id, "user_id": user_principal_id}) s3.log_operation( audit_entry=s3.GroupAccessAuditEntry( From dd47c6b03948c17098df88995f77654c29789090 Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Mon, 2 Sep 2024 12:30:08 +0500 Subject: [PATCH 24/64] fix: pass correct event to the get_and_delete_scheduled_revoke_event_if_already_exist --- src/schedule.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/schedule.py b/src/schedule.py index e0a4b9b..aa44f65 100644 --- a/src/schedule.py +++ b/src/schedule.py @@ -98,7 +98,7 @@ def delete_schedule(client: EventBridgeSchedulerClient, schedule_name: str) -> N def get_and_delete_scheduled_revoke_event_if_already_exist( client: EventBridgeSchedulerClient, - event: sso.UserAccountAssignment | GroupRevokeEvent, + event: sso.UserAccountAssignment | sso.GroupAssignment, ) -> None: for scheduled_event in get_scheduled_events(client): if isinstance(scheduled_event, ScheduledRevokeEvent) and scheduled_event.revoke_event.user_account_assignment == event: @@ -169,7 +169,7 @@ def schedule_group_revoke_event( group_assignment= group_assignment, permission_duration=permission_duration, ) - get_and_delete_scheduled_revoke_event_if_already_exist(schedule_client, revoke_event) + 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"}, From 9461be7318768e91151825eb4ecebc3bfa4622ee Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Mon, 2 Sep 2024 12:41:59 +0500 Subject: [PATCH 25/64] feat: add more logs to get_and_delete_scheduled_revoke_event_if_already_exist --- src/schedule.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/schedule.py b/src/schedule.py index aa44f65..0c8a09d 100644 --- a/src/schedule.py +++ b/src/schedule.py @@ -101,6 +101,7 @@ def get_and_delete_scheduled_revoke_event_if_already_exist( event: sso.UserAccountAssignment | sso.GroupAssignment, ) -> None: for scheduled_event in get_scheduled_events(client): + 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) From 394233c905b72d94fa9609cb1f6319fdc5080403 Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Mon, 2 Sep 2024 12:57:41 +0500 Subject: [PATCH 26/64] fix: provide membership_id in any case --- src/access_control.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/access_control.py b/src/access_control.py index 3c833d0..c8c522e 100644 --- a/src/access_control.py +++ b/src/access_control.py @@ -230,24 +230,32 @@ def execute_decision_on_group_request( # noqa: PLR0913 logger.info("Access request denied") return False # Temporary solution for testing - response = {} - if sso.is_user_in_group( + 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}) - + logger.info("User is already in the group",extra={ + "group_id": group.id, + "user_id": user_principal_id, + "membership_id": membership_id + } + ) else: - response = sso.add_user_to_a_group(group.id, user_principal_id, identity_store_id, identitystore_client) - logger.info("User added to the group",extra={"group_id": group.id, "user_id": user_principal_id}) + 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.GroupAccessAuditEntry( group_name = group.name, group_id = group.id, - membership_id = response.get("MembershipId", "NA"), + membership_id = membership_id, reason = reason, requester_slack_id = requester.id, requester_email = requester.email, @@ -270,7 +278,7 @@ def execute_decision_on_group_request( # noqa: PLR0913 group_name=group.name, group_id=group.id, user_principal_id=user_principal_id, - membership_id=response.get("MembershipId", "NA") + membership_id=membership_id, ), ) return# type: ignore # noqa: PGH003 From 07b6bab05c5c026d67664fd96e70987f09558ad9 Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Mon, 2 Sep 2024 13:16:32 +0500 Subject: [PATCH 27/64] fix: add group_revoke_event to the get_scheduled_events --- src/schedule.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/schedule.py b/src/schedule.py index 0c8a09d..7cf2f57 100644 --- a/src/schedule.py +++ b/src/schedule.py @@ -81,6 +81,8 @@ def get_scheduled_events(client: EventBridgeSchedulerClient) -> list[ScheduledRe if isinstance(event.__root__, ScheduledRevokeEvent): scheduled_revoke_events.append(event.__root__) + elif isinstance(event.__root__, GroupRevokeEvent): + scheduled_revoke_events.append(event.__root__) return scheduled_revoke_events From 0bdd62d6b7d1edfabae893f5e0d8c49ac471579a Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Mon, 2 Sep 2024 13:27:03 +0500 Subject: [PATCH 28/64] feat: more logs for get_scheduled_events --- src/schedule.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/schedule.py b/src/schedule.py index 7cf2f57..41d89ac 100644 --- a/src/schedule.py +++ b/src/schedule.py @@ -66,6 +66,7 @@ def get_schedules(client: EventBridgeSchedulerClient) -> list[scheduler_type_def def get_scheduled_events(client: EventBridgeSchedulerClient) -> list[ScheduledRevokeEvent | GroupRevokeEvent]: scheduled_events = get_schedules(client) + logger.debug("Scheduled events", extra={"scheduled_events": scheduled_events}) scheduled_revoke_events: list[ScheduledRevokeEvent | GroupRevokeEvent] = [] for full_schedule in scheduled_events: if full_schedule["Name"].startswith("discard-buttons"): From 6284261b2f6c182fe726131b26c61f2450431b71 Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Mon, 2 Sep 2024 13:37:49 +0500 Subject: [PATCH 29/64] fix: get right type of events --- src/schedule.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/schedule.py b/src/schedule.py index 41d89ac..280c255 100644 --- a/src/schedule.py +++ b/src/schedule.py @@ -13,7 +13,15 @@ import config import entities import sso -from events import ApproverNotificationEvent, DiscardButtonsEvent, Event, GroupRevokeEvent, RevokeEvent, ScheduledRevokeEvent +from events import ( + ApproverNotificationEvent, + DiscardButtonsEvent, + Event, + GroupRevokeEvent, + RevokeEvent, + ScheduledRevokeEvent, + ScheduledGroupRevokeEvent +) logger = config.get_logger(service="schedule") cfg = config.get_config() @@ -64,10 +72,10 @@ def get_schedules(client: EventBridgeSchedulerClient) -> list[scheduler_type_def return scheduled_events -def get_scheduled_events(client: EventBridgeSchedulerClient) -> list[ScheduledRevokeEvent | GroupRevokeEvent]: +def get_scheduled_events(client: EventBridgeSchedulerClient) -> list[ScheduledRevokeEvent | ScheduledGroupRevokeEvent]: scheduled_events = get_schedules(client) logger.debug("Scheduled events", extra={"scheduled_events": scheduled_events}) - scheduled_revoke_events: list[ScheduledRevokeEvent | GroupRevokeEvent] = [] + scheduled_revoke_events: list[ScheduledRevokeEvent | ScheduledGroupRevokeEvent] = [] for full_schedule in scheduled_events: if full_schedule["Name"].startswith("discard-buttons"): continue @@ -82,9 +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__, GroupRevokeEvent): + 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 From 5e3598d0bb4f218bf3c411a8ecc0c5ca1277ad60 Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Mon, 2 Sep 2024 13:49:24 +0500 Subject: [PATCH 30/64] fix: use right class in get_and_delete_scheduled_revoke_event_if_already_exist --- src/schedule.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/schedule.py b/src/schedule.py index 280c255..c560f3c 100644 --- a/src/schedule.py +++ b/src/schedule.py @@ -116,9 +116,9 @@ def get_and_delete_scheduled_revoke_event_if_already_exist( 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, GroupRevokeEvent) and scheduled_event.group_assignment == event: - logger.info("Schedule already exist, deleting it", extra={"schedule_name": scheduled_event.schedule_name}) - delete_schedule(client, scheduled_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) From 2c2b807a546ef187bdf7917a611dd197ddeaf62f Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Tue, 3 Sep 2024 12:24:00 +0500 Subject: [PATCH 31/64] feat: GroupMembership class --- src/entities/aws.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/entities/aws.py b/src/entities/aws.py index 928dfcf..0a707b1 100644 --- a/src/entities/aws.py +++ b/src/entities/aws.py @@ -18,3 +18,9 @@ class SSOGroup(BaseModel): 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 From 524fab448fa16f7fb2eb29e357da97355e35661a Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Tue, 3 Sep 2024 12:24:31 +0500 Subject: [PATCH 32/64] feat: more logs & get_groups_from_config, not all groups --- src/sso.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/sso.py b/src/sso.py index f26aa5d..a11e8cb 100644 --- a/src/sso.py +++ b/src/sso.py @@ -355,8 +355,8 @@ def get_account_assignment_information( #-----------------Group Assignments-----------------# -def get_all_groups(identity_store_id: str, identity_store_client: IdentityStoreClient) -> list[entities.aws.SSOGroup]: - +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): @@ -368,13 +368,13 @@ def get_all_groups(identity_store_id: str, identity_store_client: IdentityStoreC description=group.get("Description"), ) for group in page["Groups"] - if group.get("DisplayName") and group.get("GroupId") + if group.get("DisplayName") and group.get("GroupId") in cfg.groups ) - logger.info("Got information about all 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 information about all groups", extra={"error": e}) + logger.error("Error while getting groups from config", extra={"error": e}) raise e From 9e0ee531a9079dc13c0f7ba4b497b2ce6b23b46a Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Tue, 3 Sep 2024 12:25:03 +0500 Subject: [PATCH 33/64] feat: refactor is_user_in_group to more easely reuse list_group_memberships --- src/sso.py | 36 ++++++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/src/sso.py b/src/sso.py index a11e8cb..cc888d8 100644 --- a/src/sso.py +++ b/src/sso.py @@ -398,16 +398,36 @@ def remove_user_from_group(identity_store_id: str, membership_id: str, identity_ logger.debug("User removed from the group", extra={"responce": responce}) return responce -def is_user_in_group(identity_store_id: str, group_id: str, sso_user_id: str, identity_store_client: IdentityStoreClient) -> str | None: + +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): - for group in page["GroupMemberships"]: - try: - if group["MemberId"]["UserId"] == sso_user_id: # type: ignore # noqa: PGH003 - logger.info("User is in the group", extra={"group": group}) - return group["MembershipId"] # type: ignore # noqa: PGH003 (ignoring this because we checked if user is in the group) - except Exception as e: - logger.error("Error while checking if user is in the group", extra={"error": e}) + 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 From 39a4cdec6676e5678b0a7fbf37e81609adcb9895 Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Tue, 3 Sep 2024 12:25:21 +0500 Subject: [PATCH 34/64] feat: get group assignments from config --- src/sso.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/sso.py b/src/sso.py index cc888d8..a1fc72d 100644 --- a/src/sso.py +++ b/src/sso.py @@ -441,4 +441,21 @@ def describe_group(identity_store_id: str, group_id: str, identity_store_client: description = group.get("Description"), ) -#-----------------Group Assignments-----------------# +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 From 2aad9544a023bf3edb71e36b95e4c854617b8a9b Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Tue, 3 Sep 2024 12:26:43 +0500 Subject: [PATCH 35/64] feat: rm unused, and fix import --- src/events.py | 1 - src/main.py | 2 +- src/schedule.py | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/events.py b/src/events.py index 5deceb1..ff2bfd5 100644 --- a/src/events.py +++ b/src/events.py @@ -18,7 +18,6 @@ class RevokeEvent(BaseModel): class GroupRevokeEvent(BaseModel): - action: Literal["event_bridge_group_revoke"] schedule_name: str approver: entities.slack.User requester: entities.slack.User diff --git a/src/main.py b/src/main.py index f77cd79..81f26ea 100644 --- a/src/main.py +++ b/src/main.py @@ -66,7 +66,7 @@ def load_select_options_for_group_access_request(client: WebClient, body: dict) 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_all_groups(sso_instance.identity_store_id, identity_store_client) + groups = sso.get_groups_from_config(sso_instance.identity_store_id, identity_store_client, cfg) trigger_id = body["trigger_id"] view = slack_helpers.RequestForGroupAccessView.update_with_groups(groups=groups) diff --git a/src/schedule.py b/src/schedule.py index c560f3c..e007bcc 100644 --- a/src/schedule.py +++ b/src/schedule.py @@ -174,7 +174,6 @@ def schedule_group_revoke_event( logger.info("Scheduling revoke event") schedule_name = f"{cfg.revoker_function_name}" + datetime.now().strftime("%Y-%m-%d-%H-%M-%S") revoke_event = GroupRevokeEvent( - action="event_bridge_group_revoke", schedule_name=schedule_name, approver=approver, requester=requester, From e7323d581e8cbf234267ebeeb4699b1bb7e6bf3d Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Tue, 3 Sep 2024 12:27:12 +0500 Subject: [PATCH 36/64] feat: check on inconsistency & scheduler revokation --- src/revoker.py | 109 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 106 insertions(+), 3 deletions(-) diff --git a/src/revoker.py b/src/revoker.py index ecdb94d..748d7c5 100644 --- a/src/revoker.py +++ b/src/revoker.py @@ -22,11 +22,11 @@ CheckOnInconsistency, DiscardButtonsEvent, Event, + GroupRevokeEvent, RevokeEvent, + ScheduledGroupRevokeEvent, ScheduledRevokeEvent, SSOElevatorScheduledRevocation, - ScheduledGroupRevokeEvent, - GroupRevokeEvent ) logger = config.get_logger(service="revoker") @@ -262,7 +262,7 @@ def handle_scheduled_group_assignment_deletion( # noqa: PLR0913 slack_client: slack_sdk.WebClient, identitystore_client: IdentityStoreClient, ) -> SlackResponse | None: - logger.info("Handling scheduled group access revokation", extra={"revoke_event": GroupRevokeEvent}) + 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( @@ -343,6 +343,109 @@ def handle_check_on_inconsistency( # noqa: PLR0913 ) + +def check_on_groups_inconsistency( + identity_store_id: str, + identity_store_client: IdentityStoreClient, + sso_client: SSOAdminClient, + scheduler_client: EventBridgeSchedulerClient, + events_client: EventBridgeClient, + cfg: config.Config, + slack_client: slack_sdk.WebClient +) -> None: + 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_id: str, + identity_store_client: IdentityStoreClient, + sso_client: SSOAdminClient, + scheduler_client: EventBridgeSchedulerClient, + cfg: config.Config, + slack_client: slack_sdk.WebClient +) -> None: + 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.GroupAccessAuditEntry( + group_name = group_assignment.group_name, + group_id = group_assignment.group_id, + membership_id = group_assignment.membership_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", + 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, From 5803a552dbcb988e55d5d3938563783e16bf71d2 Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Tue, 3 Sep 2024 12:28:39 +0500 Subject: [PATCH 37/64] feat: get groups from statements & more logs --- src/access_control.py | 2 +- src/config.py | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/access_control.py b/src/access_control.py index c8c522e..8a40398 100644 --- a/src/access_control.py +++ b/src/access_control.py @@ -264,7 +264,7 @@ def execute_decision_on_group_request( # noqa: PLR0913 operation_type = "grant", permission_duration = permission_duration, audit_entry_type = "group", - user_principal_id = "" + user_principal_id = user_principal_id, ), ) diff --git a/src/config.py b/src/config.py index 1377508..e9de5f5 100644 --- a/src/config.py +++ b/src/config.py @@ -40,6 +40,9 @@ def to_set_if_list_or_str(v: list | str) -> frozenset[str]: } ) +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 @@ -63,6 +66,7 @@ class Config(BaseSettings): accounts: frozenset[str] permission_sets: frozenset[str] + groups: frozenset[str] s3_bucket_for_audit_entry_name: str s3_bucket_prefix_for_partitions: str @@ -84,6 +88,7 @@ class Config: 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 + groups = get_groups_from_statements(group_statements) permission_sets = set() accounts = set() for statement in statements: @@ -95,6 +100,7 @@ def get_accounts_and_permission_sets(cls, values: dict) -> dict: # noqa: ANN101 "permission_sets": permission_sets, "statements": frozenset(statements), "group_statements": frozenset(group_statements), + "groups": groups, } From 6cc127d7bbb0f697ec56612b6f5c498ae7b6bd23 Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Tue, 3 Sep 2024 12:43:57 +0500 Subject: [PATCH 38/64] fix: add check_on_groups_inconsistency & handle_sso_elevator_group_scheduled_revocation to event handlers --- src/revoker.py | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/src/revoker.py b/src/revoker.py index 748d7c5..5755879 100644 --- a/src/revoker.py +++ b/src/revoker.py @@ -79,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, @@ -92,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, @@ -344,8 +358,7 @@ def handle_check_on_inconsistency( # noqa: PLR0913 -def check_on_groups_inconsistency( - identity_store_id: str, +def check_on_groups_inconsistency( # noqa: PLR0913 identity_store_client: IdentityStoreClient, sso_client: SSOAdminClient, scheduler_client: EventBridgeSchedulerClient, @@ -353,6 +366,9 @@ def check_on_groups_inconsistency( 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 = [ @@ -393,13 +409,15 @@ def check_on_groups_inconsistency( ) def handle_sso_elevator_group_scheduled_revocation( # noqa: PLR0913 - identity_store_id: str, 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 = [ From b2b9754b1fa24668527227b7ed8106cb6fff8d2f Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Tue, 3 Sep 2024 12:54:09 +0500 Subject: [PATCH 39/64] fix: rm unneeded indent --- src/revoker.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/revoker.py b/src/revoker.py index 5755879..f9a30f9 100644 --- a/src/revoker.py +++ b/src/revoker.py @@ -399,14 +399,14 @@ def check_on_groups_inconsistency( # noqa: PLR0913 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}" - ), - ) + 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, From b796ace2becd5c1e80869e88d6e7043cd32396be Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Tue, 3 Sep 2024 13:11:19 +0500 Subject: [PATCH 40/64] feat: add GROUP_STATEMENTS to revoker config --- perm_revoker_lambda.tf | 1 + 1 file changed, 1 insertion(+) diff --git a/perm_revoker_lambda.tf b/perm_revoker_lambda.tf index 4d40f28..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 From b2baeebe9f6bf26952868074acd90483f0cb3840 Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Tue, 3 Sep 2024 13:19:46 +0500 Subject: [PATCH 41/64] fix: rm unused --- slack_handler_lambda.tf | 1 - 1 file changed, 1 deletion(-) diff --git a/slack_handler_lambda.tf b/slack_handler_lambda.tf index 3a1ddc5..3790b1a 100644 --- a/slack_handler_lambda.tf +++ b/slack_handler_lambda.tf @@ -214,7 +214,6 @@ data "aws_iam_policy_document" "slack_handler" { "identitystore:DescribeGroup", "identitystore:ListGroupMemberships", "identitystore:CreateGroupMembership", - "identitystore:DeleteGroupMembership" ] resources = ["*"] } From 57166123a7cd25e420d4e792f1341eaea4733072 Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Wed, 4 Sep 2024 12:08:17 +0500 Subject: [PATCH 42/64] feat: use new version of audit entry --- src/access_control.py | 11 +++++----- src/revoker.py | 14 ++++++++----- src/s3.py | 48 +++++++++++++++++-------------------------- 3 files changed, 34 insertions(+), 39 deletions(-) diff --git a/src/access_control.py b/src/access_control.py index 8a40398..703fe1e 100644 --- a/src/access_control.py +++ b/src/access_control.py @@ -195,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" ), ) @@ -252,19 +254,18 @@ def execute_decision_on_group_request( # noqa: PLR0913 ) s3.log_operation( - audit_entry=s3.GroupAccessAuditEntry( + audit_entry=s3.AuditEntry( group_name = group.name, group_id = group.id, - membership_id = membership_id, reason = reason, requester_slack_id = requester.id, requester_email = requester.email, - approver_slack_id = "NA", - approver_email = "NA", + approver_slack_id = approver.id, + approver_email = approver.email, operation_type = "grant", permission_duration = permission_duration, audit_entry_type = "group", - user_principal_id = user_principal_id, + sso_user_principal_id = user_principal_id, ), ) diff --git a/src/revoker.py b/src/revoker.py index f9a30f9..b9b1d87 100644 --- a/src/revoker.py +++ b/src/revoker.py @@ -156,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", ), ) @@ -250,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) @@ -280,10 +284,9 @@ def handle_scheduled_group_assignment_deletion( # noqa: PLR0913 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.GroupAccessAuditEntry( + audit_entry=s3.AuditEntry( group_name = group_assignment.group_name, group_id = group_assignment.group_id, - membership_id = group_assignment.membership_id, # type: ignore # noqa: PGH003 reason = "scheduled_revocation", requester_slack_id = group_revoke_event.requester.id, requester_email = group_revoke_event.requester.email, @@ -291,6 +294,8 @@ def handle_scheduled_group_assignment_deletion( # noqa: PLR0913 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) @@ -439,10 +444,9 @@ def handle_sso_elevator_group_scheduled_revocation( # noqa: PLR0913 else: sso.remove_user_from_group(group_assignment.identity_store_id, group_assignment.membership_id, identitystore_client) s3.log_operation( - audit_entry=s3.GroupAccessAuditEntry( + audit_entry=s3.AuditEntry( group_name = group_assignment.group_name, group_id = group_assignment.group_id, - membership_id = group_assignment.membership_id, reason = "scheduled_revocation", requester_slack_id = "NA", requester_email = "NA", @@ -451,7 +455,7 @@ def handle_sso_elevator_group_scheduled_revocation( # noqa: PLR0913 operation_type = "revoke", permission_duration = "NA", audit_entry_type = "group", - user_principal_id = group_assignment.user_principal_id, + sso_user_principal_id = group_assignment.user_principal_id, ), ) if cfg.post_update_to_slack: diff --git a/src/s3.py b/src/s3.py index c9b31b1..f042e6d 100644 --- a/src/s3.py +++ b/src/s3.py @@ -2,12 +2,13 @@ import uuid from dataclasses import asdict, dataclass from datetime import datetime, timedelta -from mypy_boto3_s3 import S3Client, type_defs +from typing import Literal import boto3 +from mypy_boto3_s3 import S3Client, type_defs from config import get_config, get_logger -from typing import Literal + cfg = get_config() logger = get_logger(service="s3") s3: S3Client = boto3.client("s3") @@ -15,36 +16,25 @@ @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 - -@dataclass -class GroupAccessAuditEntry: - group_name: str - group_id: str - membership_id: str | None - reason: str - requester_slack_id: str - requester_email: str - approver_slack_id: str - approver_email: str + reason: Literal["scheduled_revocation"] | Literal["automated_revocation"] | str operation_type: Literal["grant"] | Literal["revoke"] - permission_duration: str | timedelta + permission_duration: Literal["NA"] | timedelta + sso_user_principal_id: Literal["NA"] | str audit_entry_type: Literal["group"] | Literal["account"] - user_principal_id: str version = 1 -# Where we don't have info, we will write "NA" symbols - - -def log_operation(audit_entry: AuditEntry | GroupAccessAuditEntry) -> type_defs.PutObjectOutputTypeDef: + 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() logger.debug("Posting audit entry to s3", extra={"audit_entry": audit_entry}) logger.info("Posting audit entry to s3") From 534e011e507638e82cdf19c1eb66e12e7e7b2b17 Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Wed, 4 Sep 2024 12:44:08 +0500 Subject: [PATCH 43/64] feat: raise error if both configs are not provided --- src/config.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/config.py b/src/config.py index e9de5f5..6f17f4a 100644 --- a/src/config.py +++ b/src/config.py @@ -88,6 +88,12 @@ class Config: 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() From 0dd1e9741216a345f76f7557702338d84984f544 Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Wed, 4 Sep 2024 13:14:36 +0500 Subject: [PATCH 44/64] feat: upd readme --- README.md | 99 +++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 85 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 62ed03b..b7864a6 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: From 2ab51a1d50867e4531282caee8880a56cd52b964 Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Wed, 4 Sep 2024 13:34:14 +0500 Subject: [PATCH 45/64] fmt: precommit run -a --- README.md | 16 +- src/access_control.py | 88 ++++---- src/config.py | 2 + src/deploy_requirements.txt | 165 +++++++-------- src/entities/aws.py | 2 + src/errors.py | 1 - src/events.py | 15 +- src/group.py | 31 ++- src/main.py | 16 +- src/requirements.txt | 388 +++++++++++++++++++----------------- src/revoker.py | 121 +++++------ src/schedule.py | 5 +- src/slack_helpers.py | 29 ++- src/sso.py | 46 +++-- src/statement.py | 3 +- 15 files changed, 478 insertions(+), 450 deletions(-) diff --git a/README.md b/README.md index b7864a6..a78263f 100644 --- a/README.md +++ b/README.md @@ -468,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 @@ -492,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 | @@ -510,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 | @@ -529,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 | @@ -541,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/src/access_control.py b/src/access_control.py index 703fe1e..5132755 100644 --- a/src/access_control.py +++ b/src/access_control.py @@ -44,10 +44,10 @@ def determine_affected_statements( 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 + 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 + 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, @@ -77,16 +77,16 @@ def make_decision_on_access_request( # noqa: PLR0911 return AccessRequestDecision( grant=True, reason=DecisionReason.ApprovalNotRequired, - based_on_statements=frozenset([statement]), #type: ignore # noqa: PGH003 + 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]), #type: ignore # noqa: PGH003 + based_on_statements=frozenset([statement]), # type: ignore # noqa: PGH003 ) - decision_based_on_statements.add(statement) #type: ignore # noqa: PGH003 + 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: @@ -142,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]), #type: ignore # noqa: PGH003 + based_on_statements=frozenset([statement]), # type: ignore # noqa: PGH003 ) return ApproveRequestDecision( grant=False, permit=False, - based_on_statements=affected_statements, #type: ignore # noqa: PGH003 + based_on_statements=affected_statements, # type: ignore # noqa: PGH003 ) @@ -195,8 +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" + sso_user_principal_id=user_principal_id, + audit_entry_type="account", ), ) @@ -215,7 +215,6 @@ 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, @@ -225,7 +224,6 @@ def execute_decision_on_group_request( # noqa: PLR0913 requester: entities.slack.User, reason: str, identity_store_id: str, - ) -> bool: logger.info("Executing decision") if not decision.grant: @@ -238,48 +236,40 @@ def execute_decision_on_group_request( # noqa: PLR0913 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 - } + 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 - } - ) + 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, - ), - ) + 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 + 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 6f17f4a..3435509 100644 --- a/src/config.py +++ b/src/config.py @@ -25,6 +25,7 @@ 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): @@ -40,6 +41,7 @@ def to_set_if_list_or_str(v: list | str) -> frozenset[str]: } ) + def get_groups_from_statements(statements: set[GroupStatement]) -> frozenset[str]: return frozenset(group for statement in statements for group in statement.resource) 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 0a707b1..f83335e 100644 --- a/src/entities/aws.py +++ b/src/entities/aws.py @@ -13,12 +13,14 @@ class PermissionSet(BaseModel): 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 diff --git a/src/errors.py b/src/errors.py index f7f5364..020edf5 100644 --- a/src/errors.py +++ b/src/errors.py @@ -46,4 +46,3 @@ def wrapper(*args, **kwargs): # noqa: ANN002, ANN003, ANN202 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 ff2bfd5..c3240e5 100644 --- a/src/events.py +++ b/src/events.py @@ -16,7 +16,6 @@ class RevokeEvent(BaseModel): permission_duration: timedelta - class GroupRevokeEvent(BaseModel): schedule_name: str approver: entities.slack.User @@ -24,6 +23,7 @@ class GroupRevokeEvent(BaseModel): group_assignment: sso.GroupAssignment permission_duration: timedelta + class ScheduledGroupRevokeEvent(BaseModel): action: Literal["event_bridge_group_revoke"] revoke_event: GroupRevokeEvent @@ -34,7 +34,6 @@ def validate_payload(cls, values: dict) -> dict: # noqa: ANN101 return values - class ScheduledRevokeEvent(BaseModel): action: Literal["event_bridge_revoke"] revoke_event: RevokeEvent @@ -70,10 +69,10 @@ class ApproverNotificationEvent(BaseModel): class Event(BaseModel): __root__: ( - ScheduledRevokeEvent | - DiscardButtonsEvent | - CheckOnInconsistency | - SSOElevatorScheduledRevocation | - ApproverNotificationEvent | - ScheduledGroupRevokeEvent + ScheduledRevokeEvent + | DiscardButtonsEvent + | CheckOnInconsistency + | SSOElevatorScheduledRevocation + | ApproverNotificationEvent + | ScheduledGroupRevokeEvent ) = Field(..., discriminator="action") diff --git a/src/group.py b/src/group.py index 3b6436a..2225e9a 100644 --- a/src/group.py +++ b/src/group.py @@ -66,12 +66,12 @@ def handle_request_for_group_access_submittion( ts = slack_response["ts"] if ts is not None: schedule.schedule_discard_buttons_event( - schedule_client=schedule_client, #type: ignore # noqa: PGH003 + 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 + schedule_client=schedule_client, # type: ignore # noqa: PGH003 message_ts=ts, channel_id=cfg.slack_channel_id, time_to_wait=timedelta( @@ -114,29 +114,29 @@ def handle_request_for_group_access_submittion( 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, + 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 +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}) @@ -155,7 +155,6 @@ def handle_group_button_click(body: dict, client: WebClient, context: BoltContex 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"], @@ -182,7 +181,7 @@ def handle_group_button_click(body: dict, client: WebClient, context: BoltContex decision = access_control.make_decision_on_approve_request( action=payload.action, - statements=cfg.group_statements, #type: ignore # noqa: PGH003 + statements=cfg.group_statements, # type: ignore # noqa: PGH003 group_id=payload.request.group_id, approver_email=approver.email, requester_email=requester.email, @@ -215,13 +214,13 @@ def handle_group_button_click(body: dict, client: WebClient, context: BoltContex 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), + 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 + identity_store_id=identity_store_id, ) cache_for_dublicate_requests.clear() return client.chat_postMessage( diff --git a/src/main.py b/src/main.py index 81f26ea..e1a45e7 100644 --- a/src/main.py +++ b/src/main.py @@ -46,10 +46,10 @@ def lambda_handler(event: str, context): # noqa: ANN001, ANN201 def build_initial_form_handler( - view_class: slack_helpers.RequestForAccessView | - slack_helpers.RequestForGroupAccessView + view_class: slack_helpers.RequestForAccessView | slack_helpers.RequestForGroupAccessView, ) -> Callable[[WebClient, dict, Ack], SlackResponse]: - def show_initial_form_for_request(client: WebClient, + def show_initial_form_for_request( + client: WebClient, body: dict, ack: Ack, ) -> SlackResponse: @@ -60,8 +60,10 @@ def show_initial_form_for_request(client: WebClient, 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}) @@ -86,13 +88,13 @@ def load_select_options_for_account_access_request(client: WebClient, body: dict app.shortcut("request_for_access")( - build_initial_form_handler(view_class=slack_helpers.RequestForAccessView), #type: ignore # noqa: PGH003 - load_select_options_for_account_access_request + 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 + build_initial_form_handler(view_class=slack_helpers.RequestForGroupAccessView), # type: ignore # noqa: PGH003 + load_select_options_for_group_access_request, ) cache_for_dublicate_requests = {} 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 b9b1d87..655015a 100644 --- a/src/revoker.py +++ b/src/revoker.py @@ -156,8 +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", + sso_user_principal_id=account_assignment.user_principal_id, + audit_entry_type="account", ), ) @@ -184,9 +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( - sso_user_id= account_assignment.principal_id if isinstance( - account_assignment, sso.AccountAssignment - ) else account_assignment.user_principal_id, + 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, @@ -252,8 +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", + sso_user_principal_id=user_account_assignment.user_principal_id, + audit_entry_type="account", ), ) schedule.delete_schedule(scheduler_client, revoke_event.schedule_name) @@ -271,7 +271,6 @@ def handle_scheduled_account_assignment_deletion( # noqa: PLR0913 ) - def handle_scheduled_group_assignment_deletion( # noqa: PLR0913 group_revoke_event: GroupRevokeEvent, sso_client: SSOAdminClient, @@ -285,24 +284,24 @@ def handle_scheduled_group_assignment_deletion( # noqa: PLR0913 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" - ), - ) + 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, + group_assignment=group_assignment, sso_client=sso_client, identitystore_client=identitystore_client, slack_client=slack_client, @@ -327,7 +326,8 @@ def handle_check_on_inconsistency( # noqa: PLR0913 principal_id=scheduled_event.revoke_event.user_account_assignment.user_principal_id, principal_type="USER", ) - for scheduled_event in scheduled_revoke_events if isinstance(scheduled_event, ScheduledRevokeEvent) + for scheduled_event in scheduled_revoke_events + if isinstance(scheduled_event, ScheduledRevokeEvent) ] for account_assignment in account_assignments: @@ -335,9 +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( - sso_user_id= account_assignment.principal_id if isinstance( - account_assignment, sso.AccountAssignment - ) else account_assignment.user_principal_id, + 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, @@ -362,14 +362,13 @@ def handle_check_on_inconsistency( # noqa: PLR0913 ) - -def check_on_groups_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 + slack_client: slack_sdk.WebClient, ) -> None: sso_instance_arn = cfg.sso_instance_arn sso_instance = sso.describe_sso_instance(sso_client, sso_instance_arn) @@ -378,18 +377,20 @@ def check_on_groups_inconsistency( # noqa: PLR0913 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) + 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_user_id=group_assignment.user_principal_id, sso_client=sso_client, cfg=cfg, identitystore_client=identity_store_client, @@ -413,12 +414,13 @@ def check_on_groups_inconsistency( # noqa: PLR0913 ), ) + 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 + slack_client: slack_sdk.WebClient, ) -> None: sso_instance_arn = cfg.sso_instance_arn sso_instance = sso.describe_sso_instance(sso_client, sso_instance_arn) @@ -427,12 +429,14 @@ def handle_sso_elevator_group_scheduled_revocation( # noqa: PLR0913 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) + 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: @@ -445,23 +449,23 @@ def handle_sso_elevator_group_scheduled_revocation( # noqa: PLR0913 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, - ), - ) + 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, + group_assignment=group_assignment, sso_client=sso_client, identitystore_client=identitystore_client, slack_client=slack_client, @@ -486,7 +490,8 @@ def handle_sso_elevator_scheduled_revocation( # noqa: PLR0913 principal_id=scheduled_event.revoke_event.user_account_assignment.user_principal_id, principal_type="USER", ) - for scheduled_event in scheduled_revoke_events if isinstance(scheduled_event, ScheduledRevokeEvent) + 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/schedule.py b/src/schedule.py index e007bcc..f54fac0 100644 --- a/src/schedule.py +++ b/src/schedule.py @@ -20,7 +20,7 @@ GroupRevokeEvent, RevokeEvent, ScheduledRevokeEvent, - ScheduledGroupRevokeEvent + ScheduledGroupRevokeEvent, ) logger = config.get_logger(service="schedule") @@ -121,7 +121,6 @@ def get_and_delete_scheduled_revoke_event_if_already_exist( delete_schedule(client, scheduled_event.revoke_event.schedule_name) - def event_bridge_schedule_after(td: timedelta) -> str: now = datetime.now(timezone.utc) return f"at({(now + td).replace(microsecond=0).isoformat().replace('+00:00', '')})" @@ -177,7 +176,7 @@ def schedule_group_revoke_event( schedule_name=schedule_name, approver=approver, requester=requester, - group_assignment= group_assignment, + group_assignment=group_assignment, permission_duration=permission_duration, ) get_and_delete_scheduled_revoke_event_if_already_exist(schedule_client, group_assignment) diff --git a/src/slack_helpers.py b/src/slack_helpers.py index d67308a..57a02ea 100644 --- a/src/slack_helpers.py +++ b/src/slack_helpers.py @@ -423,13 +423,14 @@ def get_max_duration_block(cfg: config.Config) -> list[Option]: for i in range(1, cfg.max_permissions_duration_time * 2 + 1) ] + # Group -#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- -#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- -#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- -#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- -#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- -#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- +# -----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- +# -----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- +# -----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- +# -----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- +# -----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- +# -----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#-----#----- class RequestForGroupAccess(entities.BaseModel): @@ -438,6 +439,7 @@ class RequestForGroupAccess(entities.BaseModel): requester_slack_id: str permission_duration: timedelta + class RequestForGroupAccessView: __name__ = "RequestForGroupAccessView" CALLBACK_ID = "request_for_group_access_submitted" @@ -454,7 +456,7 @@ class RequestForGroupAccessView: LOADING_BLOCK_ID = "loading" @classmethod - def build(cls) -> View: # noqa: ANN102 + def build(cls) -> View: # noqa: ANN102 return View( type="modal", callback_id=cls.CALLBACK_ID, @@ -496,10 +498,9 @@ def build(cls) -> View: # noqa: ANN102 ), ], ) + @classmethod - def update_with_groups( - cls, groups: list[entities.aws.SSOGroup] # noqa: ANN102 - ) -> View: + 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( @@ -512,7 +513,7 @@ def update_with_groups( return view @classmethod - def build_select_group_input_block(cls, groups: list[entities.aws.SSOGroup]) -> InputBlock: # noqa: ANN102 + 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( @@ -525,14 +526,12 @@ def build_select_group_input_block(cls, groups: list[entities.aws.SSOGroup]) -> 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 - ], + 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 + 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(":")) diff --git a/src/sso.py b/src/sso.py index a1fc72d..4aa0748 100644 --- a/src/sso.py +++ b/src/sso.py @@ -353,9 +353,12 @@ def get_account_assignment_information( ) -#-----------------Group Assignments-----------------# +# -----------------Group Assignments-----------------# -def get_groups_from_config(identity_store_id: str, identity_store_client: IdentityStoreClient, cfg: config.Config) -> list[entities.aws.SSOGroup]: + +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 = [] @@ -364,7 +367,7 @@ def get_groups_from_config(identity_store_id: str, identity_store_client: Identi entities.aws.SSOGroup( id=group.get("GroupId"), identity_store_id=group.get("IdentityStoreId"), - name=group.get("DisplayName"), # type: ignore # noqa: PGH003 + name=group.get("DisplayName"), # type: ignore # noqa: PGH003 description=group.get("Description"), ) for group in page["Groups"] @@ -379,19 +382,21 @@ def get_groups_from_config(identity_store_id: str, identity_store_client: Identi def add_user_to_a_group( - sso_group_id: str, - sso_user_id: str, - identity_store_id: str, - identity_store_client:IdentityStoreClient + 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 + 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, + }, ) - 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}) @@ -400,10 +405,8 @@ def remove_user_from_group(identity_store_id: str, membership_id: str, identity_ def list_group_memberships( - identity_store_id: str, - group_id: str, - identity_store_client: IdentityStoreClient - ) -> list[entities.aws.GroupMembership]: + 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 = [] @@ -425,9 +428,9 @@ def list_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 + 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 member["MembershipId"] # type: ignore # noqa: PGH003 (ignoring this because we checked if user is in the group) return None @@ -435,12 +438,13 @@ def describe_group(identity_store_id: str, group_id: str, identity_store_client: 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"), + 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) diff --git a/src/statement.py b/src/statement.py index ccedc6b..b8b8b8c 100644 --- a/src/statement.py +++ b/src/statement.py @@ -65,7 +65,8 @@ class GroupStatement(BaseModel): approvers: FrozenSet[EmailStr] = Field(default_factory=frozenset) def affects(self, group_id: str) -> bool: # noqa: ANN101 - return (group_id in self.resource) + 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)) From 0083fcf5418334a1ead53a29aa72993af9fe80a5 Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Thu, 5 Sep 2024 12:43:33 +0500 Subject: [PATCH 46/64] feat: use aware datetimes to represent times in UTC. --- src/s3.py | 4 ++-- src/schedule.py | 10 +++++----- src/slack_helpers.py | 8 ++++---- src/sso.py | 5 +++-- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/s3.py b/src/s3.py index f042e6d..0abbd9a 100644 --- a/src/s3.py +++ b/src/s3.py @@ -1,7 +1,7 @@ import json import uuid from dataclasses import asdict, dataclass -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Literal import boto3 @@ -35,7 +35,7 @@ class AuditEntry: 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 f54fac0..92213b3 100644 --- a/src/schedule.py +++ b/src/schedule.py @@ -42,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}") @@ -134,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, @@ -171,7 +171,7 @@ def schedule_group_revoke_event( group_assignment: sso.GroupAssignment, ) -> 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") revoke_event = GroupRevokeEvent( schedule_name=schedule_name, approver=approver, @@ -211,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={ @@ -254,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 57a02ea..957ed29 100644 --- a/src/slack_helpers.py +++ b/src/slack_helpers.py @@ -1,6 +1,6 @@ -import datetime +import datetime import time -from datetime import timedelta +from datetime import timedelta, timezone from typing import Optional, TypeVar, Union import jmespath as jp @@ -350,14 +350,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) diff --git a/src/sso.py b/src/sso.py index 4aa0748..578db2b 100644 --- a/src/sso.py +++ b/src/sso.py @@ -1,6 +1,7 @@ from __future__ import annotations import datetime +from datetime import timezone import time from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Callable, Dict, Generator, Optional, TypeVar @@ -127,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() From 10bc4f4dc93a4b8e3980347a24f2117d04f7980c Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Thu, 5 Sep 2024 12:47:43 +0500 Subject: [PATCH 47/64] feat: fail if message is none on ButtonClickedPayload validations --- src/slack_helpers.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/slack_helpers.py b/src/slack_helpers.py index 957ed29..c5b2068 100644 --- a/src/slack_helpers.py +++ b/src/slack_helpers.py @@ -1,4 +1,4 @@ -import datetime +import datetime import time from datetime import timedelta, timezone from typing import Optional, TypeVar, Union @@ -308,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(">") @@ -320,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, @@ -559,6 +560,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(">") @@ -571,7 +573,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": RequestForGroupAccess( requester_slack_id=requester_slack_id, group_id=group_id, From 7472b28da583c42e0acf6256a0099cbcba9dbf47 Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Thu, 5 Sep 2024 12:50:27 +0500 Subject: [PATCH 48/64] fix: try fix pytest ci by bypassing config validation --- src/tests/conftest.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/tests/conftest.py b/src/tests/conftest.py index 4149d70..6570d30 100644 --- a/src/tests/conftest.py +++ b/src/tests/conftest.py @@ -22,6 +22,8 @@ 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": "[]", + "group_statements": "[]", } os.environ |= mock_env From 6a28ce7b15eb84df496b3a188b2d942701b10a34 Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Thu, 5 Sep 2024 12:52:30 +0500 Subject: [PATCH 49/64] fix: try fix ci --- src/tests/test_config.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/tests/test_config.py b/src/tests/test_config.py index 8880d22..c647926 100644 --- a/src/tests/test_config.py +++ b/src/tests/test_config.py @@ -77,6 +77,8 @@ def valid_config_dict(statements_as_json: bool = True): "approver_renotification_initial_wait_time": "15", "approver_renotification_backoff_multiplier": "2", "max_permissions_duration_time": "24", + "statements": "[]", + "group_statements": "[]", } From 6215f182993c5d4e6fbaa08893558e792d7b874e Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Thu, 5 Sep 2024 13:03:44 +0500 Subject: [PATCH 50/64] fix: try fix config --- src/tests/conftest.py | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/src/tests/conftest.py b/src/tests/conftest.py index 6570d30..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,8 +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": "[]", - "group_statements": "[]", + "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 From 083eec1b2758d4c0234b0a290ad662646e172c78 Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Thu, 5 Sep 2024 13:07:13 +0500 Subject: [PATCH 51/64] fix: fix config testing --- src/tests/test_config.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/tests/test_config.py b/src/tests/test_config.py index c647926..a2ee81d 100644 --- a/src/tests/test_config.py +++ b/src/tests/test_config.py @@ -77,8 +77,28 @@ def valid_config_dict(statements_as_json: bool = True): "approver_renotification_initial_wait_time": "15", "approver_renotification_backoff_multiplier": "2", "max_permissions_duration_time": "24", - "statements": "[]", - "group_statements": "[]", + "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, + }, + ] + ) } From 657096218528e03e54a538382dd005d4a708e32d Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Thu, 5 Sep 2024 13:09:02 +0500 Subject: [PATCH 52/64] fIx: try fix config tests --- src/tests/test_config.py | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/src/tests/test_config.py b/src/tests/test_config.py index a2ee81d..8880d22 100644 --- a/src/tests/test_config.py +++ b/src/tests/test_config.py @@ -77,28 +77,6 @@ def valid_config_dict(statements_as_json: bool = True): "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, - }, - ] - ) } From b2887caea3e440b49dc4df9cb996591ccda78f04 Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Thu, 5 Sep 2024 13:35:36 +0500 Subject: [PATCH 53/64] feat: add tests for group_config --- src/tests/strategies.py | 23 ++++++++++++++++++++++- src/tests/test_config.py | 38 +++++++++++++++++++++++++++++++++++--- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/src/tests/strategies.py b/src/tests/strategies.py index b27e5b3..61d8189 100644 --- a/src/tests/strategies.py +++ b/src/tests/strategies.py @@ -18,6 +18,9 @@ def jsonstr(strategy: SearchStrategy) -> SearchStrategy: # 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) +# group draw: +group_id = st.text(min_size=36, max_size=36, alphabet=string.ascii_letters + 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 +77,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), st.just("*")), + }, + 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..1677288 100644 --- a/src/tests/test_config.py +++ b/src/tests/test_config.py @@ -17,20 +17,41 @@ "PermissionSet": "AdministratorAccess", "Approvers": "example@gmail.com", } +VALID_GROUP_STATEMENT_DICT = { + "Resource": ["11111111-2222-3333-4444-555555555555"], + "Approvers": "example@gmail.com", + "AllowSelfApproval": True, +} + @given(strategies.statement_dict()) @settings(max_examples=100) @example({}).xfail(raises=KeyError, reason="Empty dict is not a valid statement") @example(VALID_STATEMENT_DICT) -def test_parse_statement(dict_statement: dict): +@example(VALID_GROUP_STATEMENT_DICT) +def test_parse_statement(dict_statement: dict, dict_group_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=100) +@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 +67,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 +76,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 +101,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", From 7d00450c54864b12db9e02a6c5f91871111f3897 Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Thu, 5 Sep 2024 13:40:47 +0500 Subject: [PATCH 54/64] fix: rm unused --- src/tests/test_config.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/tests/test_config.py b/src/tests/test_config.py index 1677288..f56a371 100644 --- a/src/tests/test_config.py +++ b/src/tests/test_config.py @@ -29,8 +29,7 @@ @settings(max_examples=100) @example({}).xfail(raises=KeyError, reason="Empty dict is not a valid statement") @example(VALID_STATEMENT_DICT) -@example(VALID_GROUP_STATEMENT_DICT) -def test_parse_statement(dict_statement: dict, dict_group_statement: dict): +def test_parse_statement(dict_statement: dict,): try: config.parse_statement(dict_statement) except ValidationError: From cae582de847b3947de8fd7345f0f48d2c67cafab Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Thu, 5 Sep 2024 13:56:41 +0500 Subject: [PATCH 55/64] fix: generate group id properly --- src/tests/strategies.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/tests/strategies.py b/src/tests/strategies.py index 61d8189..4f17bb0 100644 --- a/src/tests/strategies.py +++ b/src/tests/strategies.py @@ -15,11 +15,31 @@ def jsonstr(strategy: SearchStrategy) -> SearchStrategy: ) +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"] + + # Strategies for different parts of the group ID + first_ten = 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=12, max_size=12, alphabet=full_alphabet_group_id) + + return st.builds( + lambda first, second, third, fourth, fifth: f"{first}-{second}-{third}-{fourth}-{fifth}", + first_ten, + second_part, + third_part, + fourth_part, + fifth_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) -# group draw: -group_id = st.text(min_size=36, max_size=36, alphabet=string.ascii_letters + 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 + "_+=,.@-") From 4cbd21e0ee6212c585ccb2a378df9d7c25260224 Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Thu, 5 Sep 2024 14:37:44 +0500 Subject: [PATCH 56/64] fix: use correct group id format --- src/tests/test_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tests/test_config.py b/src/tests/test_config.py index f56a371..5cd2b12 100644 --- a/src/tests/test_config.py +++ b/src/tests/test_config.py @@ -18,7 +18,7 @@ "Approvers": "example@gmail.com", } VALID_GROUP_STATEMENT_DICT = { - "Resource": ["11111111-2222-3333-4444-555555555555"], + "Resource": ["11e111e1-e111-11ee-e111-1e11e1ee11e1"], "Approvers": "example@gmail.com", "AllowSelfApproval": True, } From a9f9d73f9171fc49c56dab1dcc2d00a17f832dd4 Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Thu, 5 Sep 2024 14:42:53 +0500 Subject: [PATCH 57/64] fix: rm * from group test --- src/tests/strategies.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tests/strategies.py b/src/tests/strategies.py index 4f17bb0..0b8365f 100644 --- a/src/tests/strategies.py +++ b/src/tests/strategies.py @@ -112,7 +112,7 @@ 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), st.just("*")), + "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)), From f7ce2b89a7ec26e8d473997fdc95d7d523fb9cd3 Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Thu, 5 Sep 2024 15:01:24 +0500 Subject: [PATCH 58/64] fix: add optional part to group config id testing --- src/tests/strategies.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/tests/strategies.py b/src/tests/strategies.py index 0b8365f..a1b32b8 100644 --- a/src/tests/strategies.py +++ b/src/tests/strategies.py @@ -14,27 +14,27 @@ 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"] - # Strategies for different parts of the group ID - first_ten = st.text(min_size=10, max_size=10, alphabet=lover_alphabet_group_id) + 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=12, max_size=12, alphabet=full_alphabet_group_id) return st.builds( - lambda first, second, third, fourth, fifth: f"{first}-{second}-{third}-{fourth}-{fifth}", + lambda first, second, third, fourth, fifth: f"{first}{('-' if first else '')}{second}-{third}-{fourth}-{fifth}", first_ten, second_part, third_part, fourth_part, fifth_part ) - group_id = build_group_id_strategy() # https://docs.aws.amazon.com/organizations/latest/APIReference/API_CreateAccountStatus.html From 00c3f885b5a15c920b255976bf36ab18b3ccc22d Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Thu, 5 Sep 2024 15:14:01 +0500 Subject: [PATCH 59/64] fix: try fix config ci --- src/tests/test_config.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/tests/test_config.py b/src/tests/test_config.py index 5cd2b12..bdd40ef 100644 --- a/src/tests/test_config.py +++ b/src/tests/test_config.py @@ -121,9 +121,14 @@ def test_config_load_environment_variables(dict_config: dict): config.Config() # type: ignore -@given(config_dict(statements=st.lists(strategies.statement_dict(), max_size=20))) +@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) -@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") +@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) From a0b683d71767d0b7f9552d9bf5447b503c6cf201 Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Thu, 5 Sep 2024 15:31:53 +0500 Subject: [PATCH 60/64] fix: less examples: to slow --- src/tests/strategies.py | 2 +- src/tests/test_config.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/tests/strategies.py b/src/tests/strategies.py index a1b32b8..cf95d6f 100644 --- a/src/tests/strategies.py +++ b/src/tests/strategies.py @@ -105,7 +105,7 @@ def statement_dict( @st.composite -def group_resource(draw: st.DrawFn, ): +def group_resource(draw: st.DrawFn): return draw(group_id) def group_statement_dict(): diff --git a/src/tests/test_config.py b/src/tests/test_config.py index bdd40ef..b1f9447 100644 --- a/src/tests/test_config.py +++ b/src/tests/test_config.py @@ -26,7 +26,7 @@ @given(strategies.statement_dict()) -@settings(max_examples=100) +@settings(max_examples=50) @example({}).xfail(raises=KeyError, reason="Empty dict is not a valid statement") @example(VALID_STATEMENT_DICT) def test_parse_statement(dict_statement: dict,): @@ -37,7 +37,7 @@ def test_parse_statement(dict_statement: dict,): @given(strategies.group_statement_dict()) -@settings(max_examples=100) +@settings(max_examples=50) @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): From 270de4c142c9577d43f07723d0f4366d19d3e6f7 Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Thu, 5 Sep 2024 15:41:27 +0500 Subject: [PATCH 61/64] fix: more group id parts --- src/tests/strategies.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/tests/strategies.py b/src/tests/strategies.py index cf95d6f..0f5fb4f 100644 --- a/src/tests/strategies.py +++ b/src/tests/strategies.py @@ -25,15 +25,17 @@ def build_group_id_strategy(): 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=12, max_size=12, 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: f"{first}{('-' if first else '')}{second}-{third}-{fourth}-{fifth}", + 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 + fifth_part, + sixth_part ) group_id = build_group_id_strategy() From 21609ba53a4a8ed89e47f2e965ad9217d9d09810 Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Thu, 5 Sep 2024 16:18:19 +0500 Subject: [PATCH 62/64] fix: increase deadline --- src/tests/test_config.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/tests/test_config.py b/src/tests/test_config.py index b1f9447..6167a33 100644 --- a/src/tests/test_config.py +++ b/src/tests/test_config.py @@ -10,6 +10,8 @@ from . import strategies +settings.register_profile("default", deadline=3000) + # ruff: noqa VALID_STATEMENT_DICT = { "ResourceType": "Account", From 2dad56f74cdba41d47a0611239cb747771f47c29 Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Thu, 5 Sep 2024 16:21:06 +0500 Subject: [PATCH 63/64] fix: supress HealthCheck.too_slow --- src/tests/test_config.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/tests/test_config.py b/src/tests/test_config.py index 6167a33..371ef94 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 @@ -10,7 +10,12 @@ from . import strategies -settings.register_profile("default", deadline=3000) +settings.register_profile( + "default", + deadline=3000, + suppress_health_check=[HealthCheck.too_slow], + +) # ruff: noqa VALID_STATEMENT_DICT = { From 056d231da5e87e8df02f6cff1114ce8ee2d51cc8 Mon Sep 17 00:00:00 2001 From: Anton Eremin Date: Thu, 5 Sep 2024 16:28:06 +0500 Subject: [PATCH 64/64] fix: disable HealthCheck.too_slow for individual tests --- src/tests/test_config.py | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/src/tests/test_config.py b/src/tests/test_config.py index 371ef94..a958059 100644 --- a/src/tests/test_config.py +++ b/src/tests/test_config.py @@ -10,13 +10,6 @@ from . import strategies -settings.register_profile( - "default", - deadline=3000, - suppress_health_check=[HealthCheck.too_slow], - -) - # ruff: noqa VALID_STATEMENT_DICT = { "ResourceType": "Account", @@ -33,7 +26,7 @@ @given(strategies.statement_dict()) -@settings(max_examples=50) +@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,): @@ -44,7 +37,7 @@ def test_parse_statement(dict_statement: dict,): @given(strategies.group_statement_dict()) -@settings(max_examples=50) +@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): @@ -122,7 +115,7 @@ def valid_config_dict( @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 @@ -134,7 +127,7 @@ def test_config_load_environment_variables(dict_config: dict): group_statements=st.lists(strategies.group_statement_dict(), max_size=20), ) ) -@settings(max_examples=50) +@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):