From 54429ff26e91c9126d8341a0d499d8ead6f7555e Mon Sep 17 00:00:00 2001 From: djacob-pd <37308419+djacob-pd@users.noreply.github.com> Date: Wed, 15 May 2024 10:46:03 -0400 Subject: [PATCH] EF-4826 - audit user role changes (#114) * EF-4826 - audit user role changes * Script works! * Actor rendering. * Write to CSV. * Documentation. * Docs updates. * One more docs update. * Rename var. * Unnecessary option. * Clean up role tiers. * More docs. * adds support for a new argument * Update get_info_on_all_users/README.md Co-authored-by: David John Coleman II * Add only updates option. * Update get_info_on_all_users/README.md Co-authored-by: David John Coleman II * Update get_info_on_all_users/README.md Co-authored-by: David John Coleman II --------- Co-authored-by: Lewis Rafuse Co-authored-by: David John Coleman II Co-authored-by: Lewis Rafuse <47062950+rafusel@users.noreply.github.com> --- get_info_on_all_users/README.md | 94 ++++++++- .../get_user_role_changes.py | 191 ++++++++++++++++++ get_info_on_all_users/requirements.txt | 2 + 3 files changed, 283 insertions(+), 4 deletions(-) create mode 100755 get_info_on_all_users/get_user_role_changes.py diff --git a/get_info_on_all_users/README.md b/get_info_on_all_users/README.md index 82098ae..23e7487 100644 --- a/get_info_on_all_users/README.md +++ b/get_info_on_all_users/README.md @@ -22,7 +22,7 @@ To execute the script, run this with your key: ## Get All Users With Certain Roles - `get_users_by_role.py` -This script will take a comma separated list of roles as a command line argument and fetches all the users in an account that match one of roles provided in the list. Roles will be fetched in the order that they are provided in the command line argument. Running with -v flag will show which role is being retrieved and then list the members who match it. After retrieving all the members for a given role, a tally will be shown for how many users have that role. +This script will take a comma separated list of roles as a command line argument and fetches all the users in an account that match one of roles provided in the list. Roles will be fetched in the order that they are provided in the command line argument. Running with -v flag will show which role is being retrieved and then list the members who match it. After retrieving all the members for a given role, a tally will be shown for how many users have that role. The script also creates a csv with names in the first column and users in the second. At the bottom of the CSV the totals for each role type are listed. @@ -48,7 +48,7 @@ You can also optionally turn on verbose logging in the console with the `-v` opt ## Get Team Roles of All Users - `team_roles.py` -This script will retrieves team roles for all users in a PagerDuty account that are members of any team. The default output is by user in the console, however you can optionally have the console output in comma-separated format for easier processing by using the `-c` option. +This script will retrieves team roles for all users in a PagerDuty account that are members of any team. The default output is by user in the console, however you can optionally have the console output in comma-separated format for easier processing by using the `-c` option. ### Input Format @@ -57,7 +57,7 @@ Running the script requires the provision of just one argument: a global REST AP To execute the script, run this with your key: ``` -./team_roles.py -k API-KEY-HERE +./team_roles.py -k API-KEY-HERE ``` You can also optionally turn on comma-separated formatting in the console with the `-c` option. @@ -65,4 +65,90 @@ You can also optionally turn on comma-separated formatting in the console with t ### Options - `-k`/`--api-key`: _(required)_ REST API key (should be a global key) -- `-c`/`--comma-separated`: _(optional)_ Format console output separated by commas \ No newline at end of file +- `-c`/`--comma-separated`: _(optional)_ Format console output separated by commas + +## Get User Role Changes + +This script retrieves user role changes for all users in a PagerDuty account. By default the script will output +a text formatted table to the console for user role changes in the past 24 hours. The script can optionally be configured to get role tier changes, can filter by a particular user ID, can filter by different date ranges, +and can write results to a CSV file. + +The following fields are returned by the script: +- Date: ISO datetime string when the role change occurred +- User ID: User ID of the user that the role change occurred on +- User Name (not required): The user's nane that the role change occurred on +- Role/Tier Before: The role/tier of the user before the change occurred +- Role/Tier After: The role/tier of the user after the change occurred +- Actor ID: The ID of the most specific actor that made the role change +- Actor Type: The type of the most specific actor that made the role change +- Actor Summary (not required): The display name of the most specific actor that made the role change + +### Usage + +Running the script requires one argument: a global REST API key + +``` +./get_user_role_changes.py -k API-KEY-HERE +``` + +#### Custom Date Range + +``` +./get_user_role_changes.py -k API-KEY-HERE --since 2023-05-08T05:15:00Z --until 2024-05-08T05:15:00Z +``` + +#### Filter by User ID + +``` +./get_user_role_changes.py -k API-KEY-HERE --user-id PABC123 +``` + +#### Exclude user create and deletes + +By default this script will include role changes from user creates and deletes +(None -> New Role, Old Role -> None). To exclude these from reported results use the `--only-updates` +options. + +``` +./get_user_role_changes.py -k API-KEY-HERE --only-updates +``` + +#### Role Tier Changes + +Instead of getting all role changes, get all role tier changes in table format. + +``` +./get_user_role_changes.py -k API-KEY-HERE --tier-changes +``` + +#### Write Results to CSV + +``` +./get_user_role_changes.py -k API-KEY-HERE --filename user_role_changes.csv +``` + +#### Show all user change audit records + +In some cases you may want to see all user changes or the complete role change audit record. +This option provides a basic implementation to print the records in JSON format. + +``` +./get_user_role_changes.py -k API-KEY-HERE --show-all +``` + +### Options + +To view all of the options available run the script with the help flag: + +``` +./get_user_role_changes.py --help + + -k API_KEY, --api-key API_KEY REST API key + -s SINCE, --since SINCE Start of date range to search + -u UNTIL, --until UNTIL End of date range to search + -i USER_ID, --user-id USER_ID Filter results to a single user ID + -o, --only-updates Exclude user creates and deletes from role change results + -t, --tier-changes Get user role tier changes + -a, --show-all Prints all fetched user records in JSON format + -f FILENAME, --filename FILENAME Write results to a CSV file +``` diff --git a/get_info_on_all_users/get_user_role_changes.py b/get_info_on_all_users/get_user_role_changes.py new file mode 100755 index 0000000..eb1b9b1 --- /dev/null +++ b/get_info_on_all_users/get_user_role_changes.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 + +# this script retrieves all user role audit records for a given date range +# the endpoint for audit records is https://api.pagerduty.com/audit/records + +import argparse +import csv +import json +import pdpyras +from datetime import datetime, timezone +from dateutil import parser, relativedelta +from tabulate import tabulate + +user_roles = { + 'owner': 'Owner', + 'admin': 'Global Admin', + 'user': 'Manager', + 'limited_user': 'Responder', + 'observer': 'Observer', + 'restricted_access': 'Restricted Access', + 'read_only_limited_user': 'Limited Stakeholder', + 'read_only_user': 'Stakeholder', + 'none': 'None' +} + +user_role_to_tier = { + user_roles['owner']: 'Full User', + user_roles['admin']: 'Full User', + user_roles['user']: 'Full User', + user_roles['limited_user']: 'Full User', + user_roles['observer']: 'Full User', + user_roles['restricted_access']: 'Full User', + user_roles['read_only_limited_user']: 'Stakeholder', + user_roles['read_only_user']: 'Stakeholder', + user_roles['none']: 'None' +} + +actor_types = { + 'user_reference': 'User', + 'app_reference': 'App', + 'api_key_reference': 'API Key', +} + +def get_api_path(user_id): + return f'users/{user_id}/audit/records' if user_id else 'audit/records' + +def get_api_params(since, until, user_id): + params={'since': since, 'until': until} + if not user_id: + params['root_resource_types[]'] = 'users' + return params + +def print_changes(changes, tier_changes): + header = header_row(tier_changes) + print(tabulate(header + changes, tablefmt='grid')) + +def write_changes_to_csv(changes, tier_changes, filename): + header = header_row(tier_changes) + with open(filename, 'w') as csvfile: + writer = csv.DictWriter(csvfile, fieldnames=header[0].keys()) + writer.writerows(header + changes) + +def header_row(tier_changes): + header = { + 'date': 'Date', + 'id': 'User ID', + 'summary': 'User Name', + 'before_value': 'Role Before', + 'value': 'Role After', + 'actor_id': 'Actor ID', + 'actor_type': 'Actor Type', + 'actor_summary': 'Actor Summary' + } + + if tier_changes: + header.update({'before_value': 'Role Tier Before', 'value': 'Role Tier After'}) + + return [header] + +def get_record_actor(record): + actors = record.get('actors', []) + if not len(actors): + return { + 'actor_type': '', + 'actor_id': '', + 'actor_summary': '' + } + + actor = actors[0] + return { + 'actor_type': actor_types[actor['type']], + 'actor_id': actor['id'], + 'actor_summary': actor.get('summary', '') + } + +def get_role_changes(record, only_updates): + if only_updates and record['action'] != 'update': + return [] + + # `details` and `fields` can both be null according to the API docs. + field_changes = record.get('details', {}).get('fields', []) + role_changes = filter(lambda fc: fc['name'] == 'role', field_changes) + + def format_role_change(role_change): + role_change = { + 'id': record['root_resource']['id'], + 'summary': record['root_resource'].get('summary', ''), + 'value': user_roles[role_change.get('value', 'none')], + 'before_value': user_roles[role_change.get('before_value', 'none')], + 'date': record['execution_time'] + } + role_change.update(get_record_actor(record)) + return role_change + + return list(map(format_role_change, role_changes)) + +def get_role_tier_changes(role_changes): + tier_changes = [] + for role_change in role_changes: + role_change['value'] = user_role_to_tier[role_change['value']] + role_change['before_value'] = user_role_to_tier[role_change['before_value']] + if role_change['value'] != role_change['before_value']: + tier_changes.append(role_change) + + return tier_changes + +def chunk_date_range(args): + # Mirror the default of the audit APIs, get records for the last 24 hours + since, until = args.since, args.until + if not since or not until: + now = datetime.now(timezone.utc) + yesterday = now - relativedelta.relativedelta(hours=+24) + yield (datetime.isoformat(yesterday), datetime.isoformat(now)) + return + + since = parser.isoparse(since) + until = parser.isoparse(until) + + if since > until: + raise 'Invalid date range' + + # Audit API requests have a date range limit of 31 days, so we + # split the requested date range into 30 day chunks + while True: + next_since = since + relativedelta.relativedelta(days=+30) + if next_since < until: + yield (datetime.isoformat(since), datetime.isoformat(next_since)) + since = next_since + else: + yield (datetime.isoformat(since), datetime.isoformat(until)) + return + +def main(args, session): + user_id, tier_changes = args.user_id, args.tier_changes + try: + role_changes = [] + chunked_date_range = chunk_date_range(args) + for since, until in chunked_date_range: + for record in session.iter_cursor(get_api_path(user_id), params=get_api_params(since, until, user_id)): + if args.show_all: + print(json.dumps(record)) + record_role_changes = get_role_changes(record, args.only_updates) + role_changes += record_role_changes + + changes = get_role_tier_changes(role_changes) if tier_changes else role_changes + if len(changes): + changes = sorted(changes, key=lambda rc: rc['date']) + print_changes(changes, tier_changes) + if args.filename: + write_changes_to_csv(changes, tier_changes, args.filename) + else: + print(f'No {"tier" if tier_changes else "role"} changes found.') + + except pdpyras.PDClientError as e: + print('Could not get user role change audit records') + raise e + +if __name__ == '__main__': + ap = argparse.ArgumentParser(description='Prints all user role or tier changes between the given dates') + ap.add_argument('-k', '--api-key', required=True, help='REST API key') + ap.add_argument('-s', '--since', required=False, help='Start of date range to search') + ap.add_argument('-u', '--until', required=False, help='End of date range to search') + ap.add_argument('-i', '--user-id', required=False, help='Filter results to a single user ID') + ap.add_argument('-o', '--only-updates', action='store_true', help='Exclude user creates and deletes from role change results') + ap.add_argument('-t', '--tier-changes', action='store_true', help='Get user role tier changes') + ap.add_argument('-a', '--show-all', action='store_true', help='Prints all fetched user records in JSON format') + ap.add_argument('-f', '--filename', required=False, help='Write results to a CSV file') + args = ap.parse_args() + session = pdpyras.APISession(args.api_key) + + main(args, session) diff --git a/get_info_on_all_users/requirements.txt b/get_info_on_all_users/requirements.txt index 359b868..b78dda2 100644 --- a/get_info_on_all_users/requirements.txt +++ b/get_info_on_all_users/requirements.txt @@ -1 +1,3 @@ pdpyras >= 2.0.2 +python-dateutil >= 2.8.2 +tabulate >= 0.9.0