def show_scheduled_roles(account_number, dynamo_table): """ Show scheduled repos for a given account. For each scheduled show whether scheduled time is elapsed or not. """ roles = Roles([ Role(get_role_data(dynamo_table, roleID)) for roleID in tqdm(role_ids_for_account(dynamo_table, account_number)) ]) # filter to show only roles that are scheduled roles = roles.filter(active=True) roles = [role for role in roles if (role.repo_scheduled)] header = ["Role name", "Scheduled", "Scheduled Time Elapsed?"] rows = [] curtime = int(time.time()) for role in roles: rows.append([ role.role_name, dt.fromtimestamp(role.repo_scheduled).strftime("%Y-%m-%d %H:%M"), role.repo_scheduled < curtime, ]) print(tabulate(rows, headers=header))
def _display_roles(account_number, dynamo_table, inactive=False): """ Display a table with data about all roles in an account and write a csv file with the data. Args: account_number (string) inactive (bool): show roles that have historically (but not currently) existed in the account if True Returns: None """ headers = [ "Name", "Refreshed", "Disqualified By", "Can be repoed", "Permissions", "Policies Repoable", "Services", "Repoed", "Managed Permissions", "Managed Policies Repoable" "Managed Services", ] rows = list() roles = Roles([ Role.parse_obj(get_role_data(dynamo_table, roleID)) for roleID in tqdm(role_ids_for_account(dynamo_table, account_number)) ]) if not inactive: roles = roles.filter(active=True) for role in roles: rows.append([ role.role_name, role.refreshed, role.disqualified_by, len(role.disqualified_by) == 0, role.total_permissions, role.repoable_permissions, role.repoable_services, role.repoed, role.total_managed_permissions, role.repoable_managed_permissions, role.repoable_managed_services, ]) rows = sorted(rows, key=lambda x: (x[5], x[0], x[4])) rows.insert(0, headers) # print tabulate(rows, headers=headers) t.view(rows) with open("table.csv", "w") as csvfile: csv_writer = csv.writer(csvfile) csv_writer.writerow(headers) for row in rows: csv_writer.writerow(row)
def repo_all_roles(account_number, dynamo_table, config, hooks, commit=False, scheduled=True): """ Repo all scheduled or eligible roles in an account. Collect any errors and display them at the end. Args: account_number (string) dynamo_table config commit (bool): actually make the changes scheduled (bool): if True only repo the scheduled roles, if False repo all the (eligible) roles Returns: None """ errors = [] role_ids_in_account = role_ids_for_account(dynamo_table, account_number) roles = Roles([]) for role_id in role_ids_in_account: roles.append( Role( get_role_data(dynamo_table, role_id, fields=['Active', 'RoleName', 'RepoScheduled']))) roles = roles.filter(active=True) cur_time = int(time.time()) if scheduled: roles = [ role for role in roles if (role.repo_scheduled and cur_time > role.repo_scheduled) ] LOGGER.info('Repoing these {}roles from account {}:\n\t{}'.format( 'scheduled ' if scheduled else '', account_number, ', '.join([role.role_name for role in roles]))) for role in roles: error = repo_role(account_number, role.role_name, dynamo_table, config, hooks, commit=commit) if error: errors.append(error) if errors: LOGGER.error('Error(s) during repo: \n{}'.format(errors)) else: LOGGER.info('Everything successful!')
def cancel_scheduled_repo(account_number, dynamo_table, role_name=None, is_all=None): """ Cancel scheduled repo for a role in an account """ if not is_all and not role_name: LOGGER.error('Either a specific role to cancel or all must be provided') return if is_all: roles = Roles([Role(get_role_data(dynamo_table, roleID)) for roleID in role_ids_for_account(dynamo_table, account_number)]) # filter to show only roles that are scheduled roles = [role for role in roles if (role.repo_scheduled)] for role in roles: set_role_data(dynamo_table, role.role_id, {'RepoScheduled': 0, 'ScheduledPerms': []}) LOGGER.info('Canceled scheduled repo for roles: {}'.format(', '.join([role.role_name for role in roles]))) return role_id = find_role_in_cache(dynamo_table, account_number, role_name) if not role_id: LOGGER.warn('Could not find role with name {} in account {}'.format(role_name, account_number)) return role = Role(get_role_data(dynamo_table, role_id)) if not role.repo_scheduled: LOGGER.warn('Repo was not scheduled for role {} in account {}'.format(role.role_name, account_number)) return set_role_data(dynamo_table, role.role_id, {'RepoScheduled': 0, 'ScheduledPerms': []}) LOGGER.info('Successfully cancelled scheduled repo for role {} in account {}'.format(role.role_name, role.account))
def schedule_repo(account_number, dynamo_table, config, hooks): """ Schedule a repo for a given account. Schedule repo for a time in the future (default 7 days) for any roles in the account with repoable permissions. """ scheduled_roles = [] roles = Roles([Role(get_role_data(dynamo_table, roleID)) for roleID in tqdm(role_ids_for_account(dynamo_table, account_number))]) scheduled_time = int(time.time()) + (86400 * config.get('repo_schedule_period_days', 7)) for role in roles: if role.repoable_permissions > 0 and not role.repo_scheduled: role.repo_scheduled = scheduled_time # freeze the scheduled perms to whatever is repoable right now set_role_data(dynamo_table, role.role_id, {'RepoScheduled': scheduled_time, 'ScheduledPerms': role.repoable_services}) scheduled_roles.append(role) LOGGER.info("Scheduled repo for {} days from now for account {} and these roles:\n\t{}".format( config.get('repo_schedule_period_days', 7), account_number, ', '.join([r.role_name for r in scheduled_roles]))) repokid.hooks.call_hooks(hooks, 'AFTER_SCHEDULE_REPO', {'roles': scheduled_roles})
def test_repokid_update_role_cache(self, mock_get_account_authorization_details, mock_get_aardvark_data, mock_set_role_data, mock_calculate_repo_scores, mock_update_role_data, mock_find_and_mark_inactive, mock_update_stats): hooks = {} role_data = ROLES[:3] role_data[0]['RolePolicyList'] = [{'PolicyName': 'all_services_used', 'PolicyDocument': ROLE_POLICIES['all_services_used']}] role_data[1]['RolePolicyList'] = [{'PolicyName': 'unused_ec2', 'PolicyDocument': ROLE_POLICIES['unused_ec2']}] role_data[2]['RolePolicyList'] = [{'PolicyName': 'all_services_used', 'PolicyDocument': ROLE_POLICIES['all_services_used']}] mock_get_account_authorization_details.side_effect = [role_data] mock_get_aardvark_data.return_value = AARDVARK_DATA def update_role_data(dynamo_table, account_number, role, current_policies): role.policies = [{'Policy': current_policies}] mock_update_role_data.side_effect = update_role_data config = {"aardvark_api_location": "", "connection_iam": {}, "active_filters": ["repokid.filters.age:AgeFilter"], "filter_config": {"AgeFilter": {"minimum_age": 90}, "BlacklistFilter": {}}} console_logger = logging.StreamHandler() console_logger.setLevel(logging.WARNING) repokid.cli.repokid_cli.LOGGER = logging.getLogger('test') repokid.cli.repokid_cli.LOGGER.addHandler(console_logger) dynamo_table = None account_number = '123456789012' repokid.cli.repokid_cli.update_role_cache(account_number, dynamo_table, config, hooks) roles = Roles([Role(ROLES[0]), Role(ROLES[1]), Role(ROLES[2])]) assert mock_calculate_repo_scores.mock_calls == [call(roles, 90, hooks)] # validate update data called for each role assert mock_update_role_data.mock_calls == [ call(dynamo_table, account_number, Role(ROLES[0]), {'all_services_used': ROLE_POLICIES['all_services_used']}), call(dynamo_table, account_number, Role(ROLES[1]), {'unused_ec2': ROLE_POLICIES['unused_ec2']}), call(dynamo_table, account_number, Role(ROLES[2]), {'all_services_used': ROLE_POLICIES['all_services_used']})] # all roles active assert mock_find_and_mark_inactive.mock_calls == [call(dynamo_table, account_number, [Role(ROLES[0]), Role(ROLES[1]), Role(ROLES[2])])] # TODO: validate total permission, repoable, etc are getting updated properly assert mock_update_stats.mock_calls == [call(dynamo_table, roles, source='Scan')]
def _schedule_repo(account_number, dynamo_table, config, hooks): """ Schedule a repo for a given account. Schedule repo for a time in the future (default 7 days) for any roles in the account with repoable permissions. """ scheduled_roles = [] roles = Roles([ Role.parse_obj(get_role_data(dynamo_table, roleID)) for roleID in tqdm(role_ids_for_account(dynamo_table, account_number)) ]) scheduled_time = int( time.time()) + (86400 * config.get("repo_schedule_period_days", 7)) for role in roles: if not role.aa_data: LOGGER.warning("Not scheduling %s; missing Access Advisor data", role.arn) continue if not role.repoable_permissions > 0: LOGGER.debug("Not scheduling %s; no repoable permissions", role.arn) continue if role.repo_scheduled: LOGGER.debug( "Not scheduling %s; already scheduled for %s", role.arn, role.repo_scheduled, ) continue role.repo_scheduled = scheduled_time # freeze the scheduled perms to whatever is repoable right now set_role_data( dynamo_table, role.role_id, { "RepoScheduled": scheduled_time, "ScheduledPerms": role.repoable_services, }, ) scheduled_roles.append(role) LOGGER.info( "Scheduled repo for {} days from now for account {} and these roles:\n\t{}" .format( config.get("repo_schedule_period_days", 7), account_number, ", ".join([r.role_name for r in scheduled_roles]), )) repokid.hooks.call_hooks(hooks, "AFTER_SCHEDULE_REPO", {"roles": scheduled_roles})
def test_repokid_update_role_cache(self, mock_list_roles, mock_get_role_inline_policies, mock_get_aardvark_data, mock_update_role_data, mock_find_and_mark_inactive, mock_update_filtered_roles, mock_update_aardvark_data, mock_update_repoable_data, mock_update_stats): # only active roles mock_list_roles.return_value = ROLES[:3] mock_get_role_inline_policies.side_effect = [ROLE_POLICIES['all_services_used'], ROLE_POLICIES['unused_ec2'], ROLE_POLICIES['all_services_used']] mock_get_aardvark_data.return_value = AARDVARK_DATA def update_role_data(role, current_policies): role.policies = role.policies = [{'Policy': current_policies}] mock_update_role_data.side_effect = update_role_data repokid.repokid.CONFIG = {"connection_iam": {}, "active_filters": ["repokid.filters.age:AgeFilter"], "filter_config": {"AgeFilter": {"minimum_age": 90}}} console_logger = logging.StreamHandler() console_logger.setLevel(logging.WARNING) repokid.repokid.LOGGER = logging.getLogger('test') repokid.repokid.LOGGER.addHandler(console_logger) repokid.repokid.update_role_cache('123456789012') # validate update data called for each role assert mock_update_role_data.mock_calls == [call(Role(ROLES[0]), ROLE_POLICIES['all_services_used']), call(Role(ROLES[1]), ROLE_POLICIES['unused_ec2']), call(Role(ROLES[2]), ROLE_POLICIES['all_services_used'])] # all roles active assert mock_find_and_mark_inactive.mock_calls == [call('123456789012', [Role(ROLES[0]), Role(ROLES[1]), Role(ROLES[2])])] roles = Roles([Role(ROLES[0]), Role(ROLES[1]), Role(ROLES[2])]) assert mock_update_filtered_roles.mock_calls == [call(roles)] assert mock_update_aardvark_data.mock_calls == [call(AARDVARK_DATA, roles)] # TODO: validate total permission, repoable, etc are getting updated properly assert mock_update_repoable_data.mock_calls == [call(roles)] assert mock_update_stats.mock_calls == [call(roles, source='Scan')]
def schedule_repo(account_number, dynamo_table, config): """ Schedule a repo for a given account. Schedule repo for a time in the future (default 7 days) for any roles in the account with repoable permissions. """ scheduled_roles = [] roles = Roles([ Role(get_role_data(dynamo_table, roleID)) for roleID in tqdm(role_ids_for_account(dynamo_table, account_number)) ]) scheduled_time = int( time.time()) + (86400 * config.get('repo_schedule_period_days', 7)) for role in roles: if role.repoable_permissions > 0: set_role_data(dynamo_table, role.role_id, {'RepoScheduled': scheduled_time}) scheduled_roles.append(role.role_name) LOGGER.info( "Scheduled repo for {} days from now for these roles:\n\t{}".format( config.get('repo_schedule_period_days', 7), ', '.join([r for r in scheduled_roles])))
def update_role_cache(account_number, dynamo_table, config, hooks): """ Update data about all roles in a given account: 1) list all the roles and initiate a role object with basic data including name and roleID 2) get inline policies for each of the roles 3) build a list of active roles - we'll want to keep data about roles that may have been deleted in case we need to restore them, so if we used to have a role and now we don't see it we'll mark it inactive 4) update data about the roles in Dynamo 5) mark inactive roles in Dynamo 6) load and instantiate filter plugins 7) for each filter determine the list of roles that it filters 8) update data in Dynamo about filters 9) get Aardvark data for each role 10) update Dynamo with Aardvark data 11) calculate repoable permissions/policies for all the roles 12) update Dynamo with information about how many total and repoable permissions and which services are repoable 13) update stats in Dynamo with basic information like total permissions and which filters are applicable Args: account_number (string): The current account number Repokid is being run against Returns: None """ conn = config["connection_iam"] conn["account_number"] = account_number LOGGER.info( "Getting current role data for account {} (this may take a while for large accounts)" .format(account_number)) role_data = get_account_authorization_details(filter="Role", **conn) role_data_by_id = {item["RoleId"]: item for item in role_data} # convert policies list to dictionary to maintain consistency with old call which returned a dict for _, data in role_data_by_id.items(): data["RolePolicyList"] = { item["PolicyName"]: item["PolicyDocument"] for item in data["RolePolicyList"] } roles = Roles([Role(rd) for rd in role_data]) active_roles = [] LOGGER.info("Updating role data for account {}".format(account_number)) for role in tqdm(roles): role.account = account_number current_policies = role_data_by_id[role.role_id]["RolePolicyList"] active_roles.append(role.role_id) roledata.update_role_data(dynamo_table, account_number, role, current_policies) LOGGER.info("Finding inactive roles in account {}".format(account_number)) roledata.find_and_mark_inactive(dynamo_table, account_number, active_roles) LOGGER.info("Filtering roles") plugins = FilterPlugins() # Blocklist needs to know the current account filter_config = config["filter_config"] blocklist_filter_config = filter_config.get( "BlocklistFilter", filter_config.get("BlacklistFilter")) blocklist_filter_config["current_account"] = account_number for plugin_path in config.get("active_filters"): plugin_name = plugin_path.split(":")[1] if plugin_name == "ExclusiveFilter": # ExclusiveFilter plugin active; try loading its config. Also, it requires the current account, so add it. exclusive_filter_config = filter_config.get("ExclusiveFilter", {}) exclusive_filter_config["current_account"] = account_number plugins.load_plugin(plugin_path, config=config["filter_config"].get( plugin_name, None)) for plugin in plugins.filter_plugins: filtered_list = plugin.apply(roles) class_name = plugin.__class__.__name__ for filtered_role in filtered_list: LOGGER.info("Role {} filtered by {}".format( filtered_role.role_name, class_name)) filtered_role.disqualified_by.append(class_name) for role in roles: set_role_data(dynamo_table, role.role_id, {"DisqualifiedBy": role.disqualified_by}) LOGGER.info( "Getting data from Aardvark for account {}".format(account_number)) aardvark_data = get_aardvark_data(config["aardvark_api_location"], account_number=account_number) LOGGER.info("Updating roles with Aardvark data in account {}".format( account_number)) for role in roles: try: role.aa_data = aardvark_data[role.arn] except KeyError: LOGGER.warning("Aardvark data not found for role: {} ({})".format( role.role_id, role.role_name)) else: set_role_data(dynamo_table, role.role_id, {"AAData": role.aa_data}) LOGGER.info( "Calculating repoable permissions and services for account {}".format( account_number)) batch_processing = config.get("query_role_data_in_batch", False) batch_size = config.get("batch_processing_size", 100) roledata._calculate_repo_scores( roles, config["filter_config"]["AgeFilter"]["minimum_age"], hooks, batch_processing, batch_size, ) for role in roles: LOGGER.debug( "Role {} in account {} has\nrepoable permissions: {}\nrepoable services: {}" .format( role.role_name, account_number, role.repoable_permissions, role.repoable_services, )) set_role_data( dynamo_table, role.role_id, { "TotalPermissions": role.total_permissions, "RepoablePermissions": role.repoable_permissions, "RepoableServices": role.repoable_services, }, ) LOGGER.info("Updating stats in account {}".format(account_number)) roledata.update_stats(dynamo_table, roles, source="Scan")
def _repo_all_roles(account_number, dynamo_table, config, hooks, commit=False, scheduled=True, limit=-1): """ Repo all scheduled or eligible roles in an account. Collect any errors and display them at the end. Args: account_number (string) dynamo_table config commit (bool): actually make the changes scheduled (bool): if True only repo the scheduled roles, if False repo all the (eligible) roles limit (int): limit number of roles to be repoed per run (< 0 is unlimited) Returns: None """ errors = [] role_ids_in_account = role_ids_for_account(dynamo_table, account_number) roles = Roles([]) for role_id in role_ids_in_account: roles.append( Role( get_role_data( dynamo_table, role_id, fields=["Active", "RoleName", "RepoScheduled"], ))) roles = roles.filter(active=True) cur_time = int(time.time()) if scheduled: roles = [ role for role in roles if (role.repo_scheduled and cur_time > role.repo_scheduled) ] LOGGER.info("Repoing these {}roles from account {}:\n\t{}".format( "scheduled " if scheduled else "", account_number, ", ".join([role.role_name for role in roles]), )) repokid.hooks.call_hooks(hooks, "BEFORE_REPO_ROLES", { "account_number": account_number, "roles": roles }) count = 0 repoed = Roles([]) for role in roles: if limit >= 0 and count == limit: break error = _repo_role( account_number, role.role_name, dynamo_table, config, hooks, commit=commit, scheduled=scheduled, ) if error: errors.append(error) repoed.append(role) count += 1 if errors: LOGGER.error( f"Error(s) during repo: \n{errors} (account: {account_number})") else: LOGGER.info( f"Successfully repoed {count} roles in account {account_number}") repokid.hooks.call_hooks( hooks, "AFTER_REPO_ROLES", { "account_number": account_number, "roles": repoed, "errors": errors }, )
def update_role_cache(account_number, dynamo_table, config, hooks): """ Update data about all roles in a given account: 1) list all the roles and initiate a role object with basic data including name and roleID 2) get inline policies for each of the roles 3) build a list of active roles - we'll want to keep data about roles that may have been deleted in case we need to restore them, so if we used to have a role and now we don't see it we'll mark it inactive 4) update data about the roles in Dynamo 5) mark inactive roles in Dynamo 6) load and instantiate filter plugins 7) for each filter determine the list of roles that it filters 8) update data in Dynamo about filters 9) get Aardvark data for each role 10) update Dynamo with Aardvark data 11) calculate repoable permissions/policies for all the roles 12) update Dynamo with information about how many total and repoable permissions and which services are repoable 13) update stats in Dynamo with basic information like total permissions and which filters are applicable Args: account_number (string): The current account number Repokid is being run against Returns: None """ conn = config['connection_iam'] conn['account_number'] = account_number LOGGER.info('Getting current role data for account {} (this may take a while for large accounts)'.format( account_number)) role_data = get_account_authorization_details(filter='Role', **conn) role_data_by_id = {item['RoleId']: item for item in role_data} # convert policies list to dictionary to maintain consistency with old call which returned a dict for _, data in role_data_by_id.items(): data['RolePolicyList'] = {item['PolicyName']: item['PolicyDocument'] for item in data['RolePolicyList']} roles = Roles([Role(rd) for rd in role_data]) active_roles = [] LOGGER.info('Updating role data for account {}'.format(account_number)) for role in tqdm(roles): role.account = account_number current_policies = role_data_by_id[role.role_id]['RolePolicyList'] active_roles.append(role.role_id) roledata.update_role_data(dynamo_table, account_number, role, current_policies) LOGGER.info('Finding inactive roles in account {}'.format(account_number)) roledata.find_and_mark_inactive(dynamo_table, account_number, active_roles) LOGGER.info('Filtering roles') plugins = FilterPlugins() # Blacklist needs to know the current account config['filter_config']['BlacklistFilter']['current_account'] = account_number for plugin_path in config.get('active_filters'): plugin_name = plugin_path.split(':')[1] plugins.load_plugin(plugin_path, config=config['filter_config'].get(plugin_name, None)) for plugin in plugins.filter_plugins: filtered_list = plugin.apply(roles) class_name = plugin.__class__.__name__ for filtered_role in filtered_list: LOGGER.info('Role {} filtered by {}'.format(filtered_role.role_name, class_name)) filtered_role.disqualified_by.append(class_name) for role in roles: set_role_data(dynamo_table, role.role_id, {'DisqualifiedBy': role.disqualified_by}) LOGGER.info('Getting data from Aardvark for account {}'.format(account_number)) aardvark_data = _get_aardvark_data(config['aardvark_api_location'], account_number=account_number) LOGGER.info('Updating roles with Aardvark data in account {}'.format(account_number)) for role in roles: try: role.aa_data = aardvark_data[role.arn] except KeyError: LOGGER.warning('Aardvark data not found for role: {} ({})'.format(role.role_id, role.role_name)) else: set_role_data(dynamo_table, role.role_id, {'AAData': role.aa_data}) LOGGER.info('Calculating repoable permissions and services for account {}'.format(account_number)) roledata._calculate_repo_scores(roles, config['filter_config']['AgeFilter']['minimum_age'], hooks) for role in roles: LOGGER.debug('Role {} in account {} has\nrepoable permissions: {}\nrepoable services:'.format( role.role_name, account_number, role.repoable_permissions, role.repoable_services )) set_role_data(dynamo_table, role.role_id, {'TotalPermissions': role.total_permissions, 'RepoablePermissions': role.repoable_permissions, 'RepoableServices': role.repoable_services}) LOGGER.info('Updating stats in account {}'.format(account_number)) roledata.update_stats(dynamo_table, roles, source='Scan')
def test_repokid_update_role_cache( self, mock_get_account_authorization_details, mock_get_aardvark_data, mock_set_role_data, mock_calculate_repo_scores, mock_update_role_data, mock_find_and_mark_inactive, mock_update_stats, ): hooks = {} role_data = ROLES[:3] role_data[0]["RolePolicyList"] = [{ "PolicyName": "all_services_used", "PolicyDocument": ROLE_POLICIES["all_services_used"], }] role_data[1]["RolePolicyList"] = [{ "PolicyName": "unused_ec2", "PolicyDocument": ROLE_POLICIES["unused_ec2"] }] role_data[2]["RolePolicyList"] = [{ "PolicyName": "all_services_used", "PolicyDocument": ROLE_POLICIES["all_services_used"], }] mock_get_account_authorization_details.side_effect = [role_data] mock_get_aardvark_data.return_value = AARDVARK_DATA def update_role_data(dynamo_table, account_number, role, current_policies): role.policies = [{"Policy": current_policies}] return role mock_update_role_data.side_effect = update_role_data config = { "aardvark_api_location": "", "connection_iam": {}, "active_filters": ["repokid.filters.age:AgeFilter"], "filter_config": { "AgeFilter": { "minimum_age": 90 }, "BlocklistFilter": {} }, } console_logger = logging.StreamHandler() console_logger.setLevel(logging.WARNING) repokid.cli.repokid_cli.LOGGER = logging.getLogger("test") repokid.cli.repokid_cli.LOGGER.addHandler(console_logger) dynamo_table = None account_number = "123456789012" repokid.commands.role_cache._update_role_cache(account_number, dynamo_table, config, hooks) roles = Roles([ Role.parse_obj(ROLES[0]), Role.parse_obj(ROLES[1]), Role.parse_obj(ROLES[2]), ]) assert mock_calculate_repo_scores.mock_calls == [ call(roles, 90, hooks, False, 100) ] # validate update data called for each role assert mock_update_role_data.mock_calls == [ call( dynamo_table, account_number, Role.parse_obj(ROLES[0]), {"all_services_used": ROLE_POLICIES["all_services_used"]}, ), call( dynamo_table, account_number, Role.parse_obj(ROLES[1]), {"unused_ec2": ROLE_POLICIES["unused_ec2"]}, ), call( dynamo_table, account_number, Role.parse_obj(ROLES[2]), {"all_services_used": ROLE_POLICIES["all_services_used"]}, ), ] # all roles active assert mock_find_and_mark_inactive.mock_calls == [ call( dynamo_table, account_number, [ Role.parse_obj(ROLES[0]), Role.parse_obj(ROLES[1]), Role.parse_obj(ROLES[2]), ], ) ] # TODO: validate total permission, repoable, etc are getting updated properly assert mock_update_stats.mock_calls == [ call(dynamo_table, roles, source="Scan") ]