From ae2081dd9cf2ad5db91b3995ee54b9239b295c40 Mon Sep 17 00:00:00 2001 From: jasonBirchall Date: Tue, 5 Nov 2024 12:52:01 +0000 Subject: [PATCH] :fire: Remove all code associated with Auth0 User Deletion Connects to https://github.com/ministryofjustice/operations-engineering/issues/4972 and relates to no longer needing to remove users from our Auth0 tenant on a regular basis. --- .../job-delete-auth0-inactve-users.yml | 35 --- bin/auth0_delete_inactive_users.py | 30 --- config/empty-config.yml | 2 - services/auth0_service.py | 241 ------------------ test/files/incorrect-config1.yml | 2 - test/files/incorrect-config2.yml | 2 - test/files/test-config.yml | 4 - test/files/test-inactive-users.toml | 15 -- .../test_auth0_delete_inactive_users.py | 38 --- test/test_services/test_auth0_service.py | 221 ---------------- 10 files changed, 590 deletions(-) delete mode 100644 .github/workflows/job-delete-auth0-inactve-users.yml delete mode 100644 bin/auth0_delete_inactive_users.py delete mode 100644 config/empty-config.yml delete mode 100644 services/auth0_service.py delete mode 100644 test/files/incorrect-config1.yml delete mode 100644 test/files/incorrect-config2.yml delete mode 100644 test/files/test-config.yml delete mode 100644 test/files/test-inactive-users.toml delete mode 100644 test/test_bin/test_auth0_delete_inactive_users.py delete mode 100644 test/test_services/test_auth0_service.py diff --git a/.github/workflows/job-delete-auth0-inactve-users.yml b/.github/workflows/job-delete-auth0-inactve-users.yml deleted file mode 100644 index 984e53a8b..000000000 --- a/.github/workflows/job-delete-auth0-inactve-users.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: 🤖 Delete Inactive Auth0 Users -on: - workflow_dispatch: - schedule: - - cron: 0 0 * * MON - -jobs: - delete-inactive-auth0-users: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 - with: - python-version: "3.11" - cache: "pipenv" - - name: Install Pipenv - run: | - pip install pipenv - pipenv install - - run: pipenv run python3 -m bin.auth0_delete_inactive_users - env: - LOGGING_LEVEL: ${{ secrets.LOGGING_LEVEL }} - AUTH0_CLIENT_ID: ${{ secrets.AUTH0_CLIENT_ID }} - AUTH0_CLIENT_SECRET: ${{ secrets.AUTH0_CLIENT_SECRET }} - AUTH0_DOMAIN: ${{ secrets.AUTH0_DOMAIN}} - - - name: Report failure to Slack - if: ${{ always() && github.ref == 'refs/heads/main' }} - uses: ravsamhq/notify-slack-action@472601e839b758e36c455b5d3e5e1a217d4807bd # 2.5.0 - with: - status: ${{ job.status }} - notify_when: "failure" - notification_title: "Failed GitHub Action Run" - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} diff --git a/bin/auth0_delete_inactive_users.py b/bin/auth0_delete_inactive_users.py deleted file mode 100644 index 25d0126a0..000000000 --- a/bin/auth0_delete_inactive_users.py +++ /dev/null @@ -1,30 +0,0 @@ -import os - -from services.auth0_service import Auth0Service - - -def get_auth0_client_details() -> tuple[str, str, str] | None: - client_secret = os.getenv('AUTH0_CLIENT_SECRET') - client_id = os.getenv('AUTH0_CLIENT_ID') - domain = os.getenv('AUTH0_DOMAIN') - - if not all([client_secret, client_id, domain]): - raise ValueError( - 'Required environment variables AUTH0_CLIENT_SECRET, AUTH0_CLIENT_ID, or AUTH0_DOMAIN are not set') - - return client_secret, client_id, domain - - -def main(): - auth0_client_secret, auth0_client_id, auth0_domain = get_auth0_client_details() - - Auth0Service( - client_secret=auth0_client_secret, - client_id=auth0_client_id, - domain=auth0_domain, - grant_type="client_credentials" - ).delete_inactive_users() - - -if __name__ == "__main__": - main() diff --git a/config/empty-config.yml b/config/empty-config.yml deleted file mode 100644 index d7f21f779..000000000 --- a/config/empty-config.yml +++ /dev/null @@ -1,2 +0,0 @@ -ignore_teams: -badly_named_repositories: diff --git a/services/auth0_service.py b/services/auth0_service.py deleted file mode 100644 index ee0eca285..000000000 --- a/services/auth0_service.py +++ /dev/null @@ -1,241 +0,0 @@ -import logging -import time -from datetime import datetime, timedelta - -import requests - -RESPONSE_OKAY = 200 -RESPONSE_NO_CONTENT = 204 -logging.basicConfig(level=logging.INFO) - - -class Auth0Service: - def __init__(self, client_secret: str, client_id: str, domain: str, grant_type: str = 'client_credentials'): - """ - Args: - client_secret (str): Client Secret from Auth0 - client_id (str): Client ID from Auth0 - domain (str): Domain from Auth0 - grant_type (str, optional): Grant type used to acquire access token. Defaults to 'client_credentials'. - """ - self.client_secret = client_secret - self.client_id = client_id - self.domain = domain - self.audience = f"https://{domain}/api/v2/" - self.grant_type = grant_type - self.access_token = self.get_access_token() - - def _make_request(self, method: str, endpoint: str, data: dict[str, any] = None) -> requests.Response: - """Base request utility function - - Args: - method (str): API method to use - endpoint (str): API endpoint to use - data (dict[str, any], optional): Body data to use, defaults to None - - Raises: - Exception: If the request fails - - Returns: - requests.Response: Response object - """ - - logging.debug("Making %s request to %s", method, endpoint) - - # Create endpoint URL and headers using domain and access token - url = f"https://{self.domain}/{endpoint}" - headers = {'content-type': 'application/json', - 'Authorization': f'Bearer {self.access_token}'} - - if data is not None: - # Makes a request based on the given method, URL, headers and data - response = requests.request( - method, url, json=data, headers=headers, timeout=10) - else: - # Makes a request based on the given method, URL and headers only - response = requests.request( - method, url, headers=headers, timeout=10) - - return response - - def get_access_token(self) -> str: - """Gets an access token from Auth0 - - Raises: - Exception: If the request fails - - Returns: - str: Access token - """ - - logging.info("Getting access token from Auth0") - - # Create endpoint URL and headers using domain and access token - url = f"https://{self.domain}/oauth/token" - headers = {'content-type': 'application/json'} - - # Create body data - payload = { - 'client_id': self.client_id, - 'client_secret': self.client_secret, - 'audience': self.audience, - 'grant_type': self.grant_type - } - - # Makes request - response = requests.post( - url, json=payload, headers=headers, timeout=10) - - if response.status_code == RESPONSE_OKAY: - logging.info("Access token received") - # Returns the received access token. - return response.json()['access_token'] - - logging.error( - "Failed to get access token from Auth0: %s", response.text) - raise Exception("Failed to get access token from Auth0") - - def _get(self, endpoint: str) -> requests.Response: - """Sends a GET request to the API endpoint. - - Args: - endpoint (str): Auth0 API endpoint to send the request to. - - Returns: - requests.Response: Response object - """ - logging.debug("Getting data from %s", endpoint) - return self._make_request('GET', endpoint) - - def _delete(self, endpoint: str) -> requests.Response: - """Sends a DELETE request to the API endpoint. - - Args: - endpoint (str): API endpoint to send the request to. - - Returns: - requests.Response: Response object - """ - logging.debug("Deleting data from %s", endpoint) - return self._make_request('DELETE', endpoint) - - def delete_inactive_users(self, days_inactive: int = 90) -> None: - self._delete_users(self.get_inactive_users(days_inactive)) - - def _delete_users(self, users: list[dict[str, any]]) -> None: - for user in users: - logging.debug("Deleting user %s", user['user_id']) - self.delete_user(user['user_id']) - - def delete_user(self, user_id: str) -> requests.Response: - """Deletes a user from Auth0 - - Args: - user_id (str): ID of the user in Auth0 - - Returns: - requests.Response: Response object - """ - response = self._delete(f'api/v2/users/{user_id}') - - if response.status_code == RESPONSE_NO_CONTENT: - logging.info("User %s deleted", user_id) - else: - logging.error("Failed to delete user %s: %s", - user_id, response.text) - - time.sleep(1) - - return response.status_code - - def _get_users(self, page=0, per_page=100) -> list[dict[str, any]]: - """Gets all users from Auth0 - - Args: - page (int, optional): Page number to retrieve. Defaults to 0. - per_page (int, optional): Number of users to retrieve per page. Defaults to 100. - - Returns: - list[dict[str, any]]: A dictionary containing all users - """ - - # List to hold users - all_users = [] - - # Paginate through users - while True: - response = self._get( - f'api/v2/users?page={page}&per_page={per_page}') - if response.status_code == RESPONSE_OKAY: - users = response.json() - logging.debug("Users retrieved") - for user in users: - all_users.append(user) - # Out of users - if len(users) < per_page: - break - page += 1 - time.sleep(0.5) - else: - logging.error("Failed to get users: %s", response.text) - break - - return all_users - - def get_inactive_users(self, days_inactive: int = 90) -> list[dict[str, any]]: - """Gets all users who have not logged in for a given number of days - - Args: - days_inactive (int, optional): how many days is classed as inactive. Defaults to 90. - - Returns: - list[dict[str, any]]: List of users - """ - - def is_user_inactive(user: dict) -> bool: - """Check if a user is inactive. - - A user is considered inactive if they have never logged in or their last login was more than `days_inactive` days ago. - - Args: - user (dict): The user to check. - - Returns: - bool: True if the user is inactive, False otherwise. - """ - last_login_str = user.get('last_login') - if last_login_str is None: - # User has never logged in - return True - - last_login = datetime.strptime( - last_login_str, '%Y-%m-%dT%H:%M:%S.%fZ') - return last_login < (datetime.utcnow() - timedelta(days=days_inactive)) - - return [user for user in self._get_users() if is_user_inactive(user)] - - def get_active_users(self, days_inactive: int = 90) -> list[dict[str, any]]: - """Gets all users who have logged in for a given number of days - - Args: - days_inactive (int, optional): how many days is classed as inactive. Defaults to 90. - - Returns: - list[dict[str, any]]: list of users - """ - - # List to hold active users - users2 = [] - for user in self._get_users(): - if user.get('last_login'): - last_login = datetime.strptime( - user['last_login'], '%Y-%m-%dT%H:%M:%S.%fZ') - if last_login > (datetime.utcnow() - timedelta(days=days_inactive)): - users2.append(user) - return users2 - - def get_active_users_usernames(self) -> list[str]: - return [user["nickname"].lower() for user in self.get_active_users()] - - def get_active_case_sensitive_usernames(self) -> list[str]: - return [user["nickname"] for user in self.get_active_users()] diff --git a/test/files/incorrect-config1.yml b/test/files/incorrect-config1.yml deleted file mode 100644 index 6a5a14131..000000000 --- a/test/files/incorrect-config1.yml +++ /dev/null @@ -1,2 +0,0 @@ -no_teams: -badly_named_repositories: diff --git a/test/files/incorrect-config2.yml b/test/files/incorrect-config2.yml deleted file mode 100644 index 593139c32..000000000 --- a/test/files/incorrect-config2.yml +++ /dev/null @@ -1,2 +0,0 @@ -teams: -no_badly_named_repositories: diff --git a/test/files/test-config.yml b/test/files/test-config.yml deleted file mode 100644 index 08cef1094..000000000 --- a/test/files/test-config.yml +++ /dev/null @@ -1,4 +0,0 @@ -ignore_teams: - - ignoreteam1 -badly_named_repositories: - - "repo1234" diff --git a/test/files/test-inactive-users.toml b/test/files/test-inactive-users.toml deleted file mode 100644 index 062f5c4fb..000000000 --- a/test/files/test-inactive-users.toml +++ /dev/null @@ -1,15 +0,0 @@ -[github] -organisation_name = "some-org" - -[activity_check] -inactivity_months = 18 - -[team.some_team] -github_team = "team-name" -slack_channel = "#team-name-alerts" -remove_from_team = true -users_to_ignore = [ "some-user1", "some-user2" ] -repositories_to_ignore = [ "ignore-repository" ] - -# Add more teams here - diff --git a/test/test_bin/test_auth0_delete_inactive_users.py b/test/test_bin/test_auth0_delete_inactive_users.py deleted file mode 100644 index 97f63c99c..000000000 --- a/test/test_bin/test_auth0_delete_inactive_users.py +++ /dev/null @@ -1,38 +0,0 @@ -import os -import unittest -from unittest.mock import Mock, patch - -from bin.auth0_delete_inactive_users import get_auth0_client_details, main - - -class TestMain(unittest.TestCase): - @patch('bin.auth0_delete_inactive_users.Auth0Service', return_value=Mock()) - @patch('bin.auth0_delete_inactive_users.get_auth0_client_details', return_value=('secret', 'id', 'domain')) - def test_main(self, mock_get_auth0_client_details, mock_auth0_service): - main() - mock_get_auth0_client_details.assert_called_once() - mock_auth0_service.assert_called_once_with( - client_secret='secret', - client_id='id', - domain='domain', - grant_type='client_credentials' - ) - mock_auth0_service.return_value.delete_inactive_users.assert_called_once() - - -class TestGetAuth0ClientDetails(unittest.TestCase): - @patch.dict(os.environ, {"AUTH0_CLIENT_SECRET": "secret", "AUTH0_CLIENT_ID": "id", "AUTH0_DOMAIN": "domain"}) - def test_get_auth0_client_details_success(self): - # Test that the function returns the correct values when the environment variables are set - self.assertEqual(get_auth0_client_details(), - ("secret", "id", "domain")) - - @patch.dict(os.environ, {"AUTH0_CLIENT_SECRET": "", "AUTH0_CLIENT_ID": "", "AUTH0_DOMAIN": ""}, clear=True) - def test_get_auth0_client_details_failure(self): - # Test that the function raises a ValueError when the environment variables are not set - with self.assertRaises(ValueError): - get_auth0_client_details() - - -if __name__ == "__main__": - unittest.main() diff --git a/test/test_services/test_auth0_service.py b/test/test_services/test_auth0_service.py deleted file mode 100644 index ba07e3b2e..000000000 --- a/test/test_services/test_auth0_service.py +++ /dev/null @@ -1,221 +0,0 @@ -import unittest -from datetime import datetime -from unittest.mock import MagicMock, Mock, patch - -import requests -from dateutil.relativedelta import relativedelta - -from services.auth0_service import (RESPONSE_NO_CONTENT, RESPONSE_OKAY, - Auth0Service) - -# pylint: disable=R0902, W0212 - - -class TestAuth0Service(unittest.TestCase): - def setUp(self) -> None: - self.client_secret = "test_client_secret" - self.client_id = "test_client_id" - self.domain = "test_domain" - self.grant_type = "test_grant_type" - self.endpoint = "test_endpoint" - self.the_datetime = MagicMock() - - # Pretend it is January 1st, 2022 - self.fake_date = self.the_datetime(2022, 1, 1) - - self.response = Mock() - self.response.status_code = RESPONSE_OKAY - self.response.json.return_value = {"access_token": "test_access_token"} - requests.post = MagicMock(return_value=self.response) - - self.auth0 = Auth0Service( - client_secret=self.client_secret, - client_id=self.client_id, - domain=self.domain, - grant_type=self.grant_type, - ) - - self.users_with_id = [ - { - "test_key": "test_value", - "user_id": "test_user_id1" - }, - ] - - self.users = [{"test_key": "test_value"}] - - self.active_users = [ - { - "test_key": "test_value", - "last_login": "2050-01-01T00:00:00.000Z", - "user_id": "test_user_id1" - }, - ] - - self.expired_users = [ - { - "test_key": "test_value", - "last_login": "2022-01-01T00:00:00.000Z", - "user_id": "test_user_id1" - }, - ] - - def test_constructor(self): - self.assertEqual(self.auth0.client_secret, self.client_secret) - self.assertEqual(self.auth0.client_id, self.client_id) - self.assertEqual(self.auth0.domain, self.domain) - self.assertEqual(self.auth0.grant_type, self.grant_type) - - def test_delete_user(self): - self.response.status_code = RESPONSE_NO_CONTENT - self.auth0._delete = MagicMock(return_value=self.response) - - # Call the delete_user method with the given user ID - user_id = "user123" - response = self.auth0.delete_user(user_id) - - # Check that the delete method was called with the correct URL - self.auth0._delete.assert_called_once_with("api/v2/users/user123") - - # Check that the delete_user method returns the mock response object - self.assertEqual(response, self.response.status_code) - - # Set up the mock response object again, this time with an error status code - err_response = MagicMock(spec=requests.Response) - err_response.status_code = 500 - - # Set up the mock Auth0 client object - self.auth0._delete = MagicMock(return_value=err_response) - - # Call the delete_user method with the same user ID again - response = self.auth0.delete_user(user_id) - - # Check that the delete method was called with the correct URL - self.auth0._delete.assert_called_with("api/v2/users/user123") - - # Check that the delete_user method returns the mock error response - self.assertEqual(response, err_response.status_code) - - def test_get_access_token(self): - requests.post = MagicMock(return_value=self.response) - access_token = self.auth0.get_access_token() - self.assertEqual(access_token, "test_access_token") - - def test_get_access_token_fails(self): - self.response.status_code = RESPONSE_NO_CONTENT - requests.post = MagicMock(return_value=self.response) - self.assertRaises(Exception, self.auth0.get_access_token) - - def test_get_users(self): - self.response.json.return_value = self.users - self.auth0._get = MagicMock(return_value=self.response) - users = self.auth0._get_users() - self.assertEqual(users, self.users) - - def test_get_users_with_pages(self): - large_list = self.users * 102 - small_list = self.users - joint_list = small_list + large_list - self.response.json.side_effect = [large_list, small_list] - self.auth0._get = MagicMock(return_value=self.response) - users = self.auth0._get_users(page=3) - self.assertEqual(users, joint_list) - - def test_get_users_when_error(self): - self.response.status_code = RESPONSE_NO_CONTENT - self.auth0._get = MagicMock(return_value=self.response) - users = self.auth0._get_users() - self.assertEqual(users, []) - - def test_get_inactive_users_when_user_is_active(self): - fake_date = datetime.now() - relativedelta(days=3) - the_users = self.expired_users - the_users[0].update( - {"last_login": f"{fake_date:%Y-%m-%dT%H:%M:%S.%fZ}"}) - self.response.json.return_value = the_users - self.auth0._get = MagicMock(return_value=self.response) - users = self.auth0.get_inactive_users() - self.assertEqual(users, []) - - def test_get_inactive_users(self): - self.the_datetime.utcnow.return_value = self.fake_date - self.response.json.return_value = self.expired_users - self.auth0._get = MagicMock(return_value=self.response) - users = self.auth0.get_inactive_users() - self.assertEqual(users, self.expired_users) - - def test_get_inactive_users_when_user_never_logged_in(self): - self.response.json.return_value = self.users_with_id - self.auth0._get = MagicMock(return_value=self.response) - users = self.auth0.get_inactive_users() - self.assertEqual(users, self.users_with_id) - - def test_get_active_users_when_user_has_expired(self): - self.the_datetime.utcnow.return_value = self.fake_date - self.response.json.return_value = self.expired_users - self.auth0._get = MagicMock(return_value=self.response) - users = self.auth0.get_active_users() - self.assertEqual(users, []) - - def test_get_active_users_when_user_never_logged_in(self): - self.the_datetime.utcnow.return_value = self.fake_date - self.response.json.return_value = self.users_with_id - self.auth0._get = MagicMock(return_value=self.response) - users = self.auth0.get_active_users() - self.assertEqual(users, []) - - def test_get_active_users(self): - self.response.json.return_value = self.active_users - self.auth0._get = MagicMock(return_value=self.response) - users = self.auth0.get_active_users() - self.assertEqual(users, self.active_users) - - @patch.object(requests, 'request') - def test_make_request(self, mock_requests): - mock_requests.return_value = Mock(status_code=RESPONSE_OKAY) - response = self.auth0._make_request("POST", "some-endpoint") - self.assertEqual(RESPONSE_OKAY, response.status_code) - - @patch.object(requests, 'request') - def test_make_request_with_data(self, mock_requests): - mock_requests.return_value = Mock(status_code=RESPONSE_OKAY) - response = self.auth0._make_request( - "POST", self.endpoint, "the-data") - self.assertEqual(RESPONSE_OKAY, response.status_code) - - def test_get(self): - self.auth0._make_request = MagicMock(return_value=self.response) - res = self.auth0._get(self.endpoint) - self.assertEqual(res.status_code, self.response.status_code) - - def test_delete(self): - self.auth0._make_request = MagicMock(return_value=self.response) - res = self.auth0._delete(self.endpoint) - self.assertEqual(res.status_code, self.response.status_code) - - @patch.object(Auth0Service, "get_active_users") - def test_get_active_users_usernames(self, mock_get_active_users): - mock_get_active_users.return_value = [ - {"nickname": "some-user-1"}, {"nickname": "Some-useR-2"}] - self.assertEqual(self.auth0.get_active_users_usernames(), [ - "some-user-1", "some-user-2"]) - - @patch.object(Auth0Service, 'get_inactive_users', return_value=[{'user_id': '1'}, {'user_id': '2'}]) - @patch.object(Auth0Service, '_delete_users') - def test_delete_inactive_users(self, mock_delete_users, mock_get_inactive_users): - service = self.auth0 - service.delete_inactive_users(90) - mock_get_inactive_users.assert_called_once_with(90) - mock_delete_users.assert_called_once_with( - [{'user_id': '1'}, {'user_id': '2'}]) - - @patch.object(Auth0Service, 'delete_user') - def test__delete_users(self, mock_delete_user): - service = self.auth0 - service._delete_users([{'user_id': '1'}, {'user_id': '2'}]) - mock_delete_user.assert_any_call('1') - mock_delete_user.assert_any_call('2') - - -if __name__ == "__main__": - unittest.main()