microsoft-graph-helpers 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,16 @@
1
+ Metadata-Version: 2.4
2
+ Name: microsoft_graph_helpers
3
+ Version: 0.1.0
4
+ Summary: Helper functions for interacting with Microsoft Graph API
5
+ Author: Lucas Krupinski
6
+ License: MIT
7
+ Requires-Python: >=3.12
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: requests>=2.32.5
10
+ Dynamic: requires-python
11
+
12
+ # Microsoft Graph Helpers
13
+
14
+ A Python module for calling various Microsoft Graph endpoints. Not nearly as comprehensive as Microsoft's official modules, but far lighter weight and with a minimal amount of dependencies.
15
+
16
+ Not comprehensive, only contains calls that I've needed to user in my other applets.
@@ -0,0 +1,5 @@
1
+ # Microsoft Graph Helpers
2
+
3
+ A Python module for calling various Microsoft Graph endpoints. Not nearly as comprehensive as Microsoft's official modules, but far lighter weight and with a minimal amount of dependencies.
4
+
5
+ Not comprehensive, only contains calls that I've needed to user in my other applets.
@@ -0,0 +1,30 @@
1
+ from .api_token import (
2
+ get_bearer_token,
3
+ )
4
+
5
+ from .core import (
6
+ get_headers,
7
+ make_graph_api_request,
8
+ handle_graph_api_error
9
+ )
10
+
11
+ from .email import (
12
+ send_message_as,
13
+ retrieve_message
14
+ )
15
+
16
+ from .groups import (
17
+ get_group_guid,
18
+ get_group_members,
19
+ )
20
+
21
+ from .security import (
22
+ run_hunting_query,
23
+ )
24
+
25
+ from .users import (
26
+ verify_user_exists,
27
+ revoke_ms_sessions,
28
+ reset_ms_password,
29
+ get_user_direct_group_memberships,
30
+ )
@@ -0,0 +1,92 @@
1
+ import hashlib
2
+ import json
3
+ import logging
4
+ import requests
5
+ import time
6
+
7
+ # In-memory token cache
8
+ _token_cache = {}
9
+
10
+ def _generate_cache_key(tenant_id, client_id, secret, scope):
11
+ key_string = f"{tenant_id}:{client_id}:{secret}:{scope}"
12
+ return hashlib.sha256(key_string.encode()).hexdigest()
13
+
14
+ def get_bearer_token(
15
+ tenant_id: str,
16
+ client_id: str,
17
+ client_secret: str,
18
+ scope: str = "https://graph.microsoft.com/.default"
19
+ ) -> str:
20
+ """
21
+ Fetches a bearer token from Microsoft Identity Platform using client credentials.
22
+ This function uses an in-memory cache to avoid redundant token requests.
23
+
24
+ Args:
25
+ tenant_id (str): Azure AD tenant ID.
26
+ client_id (str): Application (client) ID.
27
+ client_secret (str): Application secret.
28
+ scope (str, optional): OAuth2 scope. Defaults to Microsoft Graph.
29
+
30
+ Returns:
31
+ str: Access token string.
32
+
33
+ Raises:
34
+ TokenRequestError: If the token request fails or returns a non-200 response.
35
+ RuntimeError: If the response is not valid JSON or if the request times out.
36
+ """
37
+ logging.debug(f"get_bearer_token called for tenant_id={tenant_id[:5]}..., client_id={client_id[:5]}...")
38
+ cache_key = _generate_cache_key(tenant_id, client_id, client_secret, scope)
39
+ cached = _token_cache.get(cache_key)
40
+
41
+ if cached and cached['expires_at'] > time.time():
42
+ logging.debug(f"Retrieved cached Microsoft Graph token: {cache_key}")
43
+ return cached['token']
44
+
45
+ logging.debug(f"No valid cached Microsoft Graph token found for key: {cache_key}")
46
+ token_url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
47
+ data = {
48
+ 'client_id': client_id,
49
+ 'client_secret': client_secret,
50
+ 'scope': scope,
51
+ 'grant_type': 'client_credentials'
52
+ }
53
+ headers = {
54
+ 'Content-Type': 'application/x-www-form-urlencoded'
55
+ }
56
+
57
+ try:
58
+ response = requests.post(token_url, data=data, headers=headers, timeout=10)
59
+ try:
60
+ response_json = response.json()
61
+ except ValueError:
62
+ logging.critical("Non-JSON response received when retrieving Microsoft Graph token.")
63
+ raise RuntimeError("Invalid JSON response received when retrieving Microsoft Graph token.")
64
+
65
+ if response.status_code == 200:
66
+ token = response_json.get('access_token')
67
+ if not token:
68
+ logging.critical("Microsoft Graph token not found in the response.")
69
+ raise RuntimeError("Microsoft Graph token not found in the response.")
70
+
71
+ expires_in = response_json.get('expires_in', 3599)
72
+ _token_cache[cache_key] = {
73
+ 'token': token,
74
+ 'expires_at': time.time() + expires_in - 60
75
+ }
76
+
77
+ logging.debug(
78
+ f"Obtained Microsoft Graph bearer token with expiry at {time.ctime(_token_cache[cache_key]['expires_at'])}."
79
+ )
80
+ return token
81
+
82
+ error_dump = dict({'http_status_code': response.status_code}, **response_json)
83
+ logging.critical(json.dumps(error_dump))
84
+ raise TokenRequestError(json.dumps(error_dump, indent=4))
85
+
86
+ except requests.exceptions.Timeout:
87
+ logging.critical("Microsoft Graph token request timed out.")
88
+ raise RuntimeError("Microsoft Graph token request timed out.")
89
+
90
+ class TokenRequestError(Exception):
91
+ """Raised when a Microsoft Graph token request fails."""
92
+ pass
@@ -0,0 +1,79 @@
1
+ import json, logging, requests, time
2
+ from typing import List, Union
3
+ from requests.exceptions import Timeout, RequestException
4
+
5
+
6
+ def get_headers(bearer_token):
7
+ # returns basic request header for making Graph calls
8
+ return {
9
+ 'Content-Type': 'application/json',
10
+ 'Authorization': 'Bearer ' + bearer_token
11
+ }
12
+
13
+ def make_graph_api_request(
14
+ method: str,
15
+ url: str,
16
+ headers: dict,
17
+ payload: Union[dict, None] = None,
18
+ context: str = "",
19
+ retries: int = 3,
20
+ timeout: int = 15
21
+ ) -> Union[dict, bool]:
22
+ for attempt in range(1, retries + 1):
23
+ try:
24
+ if method.upper() == "GET":
25
+ response = requests.get(url, headers=headers, timeout=timeout)
26
+ elif method.upper() == "POST":
27
+ response = requests.post(url, headers=headers, json=payload, timeout=timeout)
28
+ elif method.upper() == "PATCH":
29
+ response = requests.patch(url, headers=headers, json=payload, timeout=timeout)
30
+ else:
31
+ logging.error(f"[{context}] Unsupported HTTP method: {method}")
32
+ return False
33
+
34
+ if response.status_code in (200, 202, 204):
35
+ if response.status_code == 204:
36
+ logging.debug(f"[{context}] Http Status Code: {response.status_code} - Request submitted successfully.")
37
+ return True
38
+ elif response.status_code == 202:
39
+ logging.debug(f"[{context}] Http Status Code: {response.status_code} - Request submitted successfully.")
40
+ return True
41
+ try:
42
+ return response.json()
43
+ except ValueError:
44
+ logging.warning(f"[{context}] Http Status Code: {response.status_code} - Response returned no JSON content.")
45
+ return True
46
+ else:
47
+ handle_graph_api_error(response, context=context)
48
+ return False
49
+
50
+ except Timeout:
51
+ logging.warning(f"[{context}] Timeout on attempt {attempt}/{retries}. Retrying...")
52
+ except RequestException as e:
53
+ logging.error(f"[{context}] RequestException: {e}")
54
+ break
55
+
56
+ time.sleep(2 ** attempt)
57
+
58
+ logging.error(f"[{context}] All retry attempts failed.")
59
+ return False
60
+
61
+ def handle_graph_api_error(response: requests.Response, context: str = "") -> None:
62
+ """
63
+ Handles and logs errors from Microsoft Graph API responses.
64
+ """
65
+ status_code = response.status_code
66
+
67
+ try:
68
+ result = response.json()
69
+ error = result.get("error", {})
70
+ code = error.get("code", "UnknownError")
71
+ message = error.get("message", "No error message provided.")
72
+ except (ValueError, AttributeError) as e:
73
+ code = "InvalidResponse"
74
+ message = f"Failed to parse error response: {e}"
75
+ logging.error(f"Raw response: {response.text}")
76
+
77
+ prefix = f"[{context}] " if context else ""
78
+ logging.warning(f"{prefix}Http Status Code: {status_code} - {code}.")
79
+ logging.warning(f"{prefix}{message}")
@@ -0,0 +1,88 @@
1
+ import json, logging, requests, urllib.parse
2
+ from typing import List, Tuple, Union
3
+ from .core import get_headers, make_graph_api_request
4
+
5
+
6
+ def send_message_as(
7
+ bearer_token: str,
8
+ sender_email: str,
9
+ recipient_email: str,
10
+ subject: str,
11
+ body: str,
12
+ save_to_sent_messages: bool = False,
13
+ ) -> bool:
14
+ logging.debug(f"Sending message from {sender_email} to {recipient_email}")
15
+ headers = get_headers(bearer_token)
16
+ payload = {
17
+ "message": {
18
+ "subject": subject,
19
+ "body": {
20
+ "contentType": "Text",
21
+ "content": body
22
+ },
23
+ "toRecipients": [
24
+ {
25
+ "emailAddress": {
26
+ "address": recipient_email
27
+ }
28
+ }
29
+ ]
30
+ },
31
+ "saveToSentItems": str(save_to_sent_messages).lower()
32
+ }
33
+
34
+ response = make_graph_api_request(
35
+ method="POST",
36
+ url=f"https://graph.microsoft.com/v1.0/users/{sender_email}/sendMail",
37
+ headers=headers,
38
+ payload=payload,
39
+ timeout=10,
40
+ context="send_message_as",
41
+ )
42
+ if response == True:
43
+ logging.debug(f"[send_message_as] Message sent to {recipient_email}")
44
+ else:
45
+ logging.debug(f"[send_message_as] Failed to send message to {recipient_email}")
46
+ return response
47
+
48
+
49
+ def retrieve_message(
50
+ bearer_token: str,
51
+ user_principal_name: str,
52
+ internet_message_id: str
53
+ ) -> Tuple[Union[dict, None], Union[str, None]]:
54
+ """
55
+ Searches for a message by its Internet Message ID across multiple folders
56
+ in the user's mailbox.
57
+
58
+ Returns:
59
+ Tuple[dict or None, str or None]: The message object and the folder name it was found in.
60
+ """
61
+ logging.debug(f"Searching for message ID \"{internet_message_id}\" in mailbox \"{user_principal_name}\"")
62
+ encoded_id = urllib.parse.quote(internet_message_id)
63
+ headers = get_headers(bearer_token)
64
+ folders = [
65
+ ("Messages", f"https://graph.microsoft.com/v1.0/users/{user_principal_name}/messages?$filter=internetMessageId eq '{encoded_id}'"),
66
+ ("DeletedItems", f"https://graph.microsoft.com/v1.0/users/{user_principal_name}/mailFolders/DeletedItems/messages?$filter=internetMessageId eq '{encoded_id}'"),
67
+ ("RecoverableItemsDeletions", f"https://graph.microsoft.com/v1.0/users/{user_principal_name}/mailFolders/RecoverableItemsDeletions/messages?$filter=internetMessageId eq '{encoded_id}'"),
68
+ ("RecoverableItemsPurges", f"https://graph.microsoft.com/v1.0/users/{user_principal_name}/mailFolders/RecoverableItemsPurges/messages?$filter=internetMessageId eq '{encoded_id}'"),
69
+ ("RecoverableItemsDiscoveryHolds", f"https://graph.microsoft.com/v1.0/users/{user_principal_name}/mailFolders/RecoverableItemsDiscoveryHolds/messages?$filter=internetMessageId eq '{encoded_id}'")
70
+ ]
71
+
72
+ for folder_name, url in folders:
73
+ logging.debug(f"Checking folder: {folder_name}")
74
+ response = make_graph_api_request(
75
+ method="GET",
76
+ url=url,
77
+ headers=headers,
78
+ timeout=10,
79
+ context=f"retrieve_message:{folder_name}"
80
+ )
81
+
82
+ if isinstance(response, dict) and response.get("value"):
83
+ logging.info(f"[retrieve_message] Message found in folder: {folder_name}")
84
+ return response, folder_name
85
+
86
+ logging.warning(f"Message ID \"{internet_message_id}\" not found in any folder for user \"{user_principal_name}\".")
87
+ return None, None
88
+
@@ -0,0 +1,40 @@
1
+ import json, logging, requests
2
+ from .core import get_headers, make_graph_api_request
3
+
4
+ def get_group_guid(bearer_token: str, group_name: str):
5
+ logging.debug(f"Obtaining GUID for group named {group_name} from Microsoft Graph API.")
6
+
7
+ url = f"https://graph.microsoft.com/v1.0/groups?$filter=displayName eq '{group_name}'"
8
+ headers = get_headers(bearer_token)
9
+ response = make_graph_api_request(
10
+ method="GET",
11
+ url=url,
12
+ headers=headers,
13
+ context="get_group_guid"
14
+ )
15
+ if isinstance(response, dict):
16
+ value = response.get("value", [])
17
+ if len(value) > 1:
18
+ logging.warning(f"Multiple groups found with name '{group_name}'. Returning the first match.")
19
+ if value and isinstance(value, list):
20
+ return value[0].get("id")
21
+ return False
22
+
23
+
24
+ def get_group_members(bearer_token: str, guid: str):
25
+ logging.debug(f"Obtaining members of group whose GUID is {guid} from Microsoft Graph API.")
26
+
27
+ url = f"https://graph.microsoft.com/v1.0/groups/{guid}/members"
28
+ headers = get_headers(bearer_token)
29
+
30
+ response = make_graph_api_request(
31
+ method="GET",
32
+ url=url,
33
+ headers=headers,
34
+ context="get_group_members"
35
+ )
36
+ if isinstance(response, dict) and 'value' in response:
37
+ logging.debug(f"Response from Microsoft Graph API contained {len(response['value'])} members.")
38
+ return [member.get('mail') for member in response['value'] if member.get('mail')]
39
+ return False
40
+
@@ -0,0 +1,26 @@
1
+ import json, logging, requests
2
+ from typing import List, Union
3
+ from .core import get_headers, make_graph_api_request
4
+
5
+ def run_hunting_query(bearer_token: str, query: str, timespan: str = "P90D") -> Union[List[dict], bool]:
6
+ logging.debug("Submitting Hunting Query as POST request to Graph API.")
7
+
8
+ url = "https://graph.microsoft.com/v1.0/security/runHuntingQuery"
9
+ headers = get_headers(bearer_token)
10
+ payload = {
11
+ "Query": query,
12
+ "Timespan": timespan
13
+ }
14
+
15
+ response = make_graph_api_request(
16
+ method="POST",
17
+ url=url,
18
+ headers=headers,
19
+ payload=payload,
20
+ context="run_hunting_query"
21
+ )
22
+
23
+ if isinstance(response, dict) and "results" in response:
24
+ logging.debug("200: Hunting query received successful response")
25
+ return response["results"]
26
+ return False
@@ -0,0 +1,85 @@
1
+ import json, logging, requests
2
+ from .core import get_headers, make_graph_api_request
3
+
4
+ def verify_user_exists(bearer_token, user_principal_name):
5
+ logging.debug(f"Querying Microsoft Graph for user {user_principal_name}")
6
+ url = f"https://graph.microsoft.com/v1.0/users/{user_principal_name}"
7
+ headers = get_headers(bearer_token)
8
+
9
+ response = make_graph_api_request(
10
+ method="GET",
11
+ url=url,
12
+ headers=headers,
13
+ context="verify_user_exists"
14
+ )
15
+
16
+ if response is False:
17
+ logging.debug(f"[verify_user_exists] User {user_principal_name} not found.")
18
+ return False
19
+ else:
20
+ logging.debug(f"[verify_user_exists] User {user_principal_name} exists.")
21
+ return True
22
+
23
+
24
+ def revoke_ms_sessions(bearer_token, user_principal_name):
25
+ logging.debug(f"[revoke_ms_sessions] Revoking sign-in sessions for {user_principal_name}")
26
+
27
+ url = f"https://graph.microsoft.com/v1.0/users/{user_principal_name}/revokeSignInSessions"
28
+ headers = get_headers(bearer_token)
29
+ response = make_graph_api_request(
30
+ method="POST",
31
+ url=url,
32
+ headers=headers,
33
+ context="revoke_ms_sessions"
34
+ )
35
+
36
+ if response is False:
37
+ logging.debug(f"[revoke_ms_sessions] for {user_principal_name} failed.")
38
+ return False
39
+ logging.debug(f"[revoke_ms_sessions] for {user_principal_name} succeeded.")
40
+ return True
41
+
42
+
43
+ def reset_ms_password(bearer_token, user_principal_name, password):
44
+ logging.debug(f"[reset_ms_password] Resetting password for {user_principal_name}.")
45
+
46
+ url = f"https://graph.microsoft.com/v1.0/users/{user_principal_name}"
47
+ headers = get_headers(bearer_token)
48
+ headers.update({'Accept': "application/json"})
49
+ payload = {
50
+ 'passwordProfile': {
51
+ 'forceChangePasswordNextSignIn': False,
52
+ 'password': password
53
+ }
54
+ }
55
+
56
+ response = make_graph_api_request(
57
+ method="PATCH",
58
+ url=url,
59
+ headers=headers,
60
+ payload=payload,
61
+ context="reset_ms_password"
62
+ )
63
+ if response is False:
64
+ logging.debug(f"reset_ms_password] Failed to reset password for {user_principal_name}.")
65
+ return False
66
+ logging.debug(f"[reset_ms_password] Reset password for {user_principal_name}.")
67
+ return False
68
+
69
+
70
+ def get_user_direct_group_memberships(bearer_token, user_principal_name):
71
+ logging.debug(f"[get_user_direct_group_memberships] Fetching group memberships for {user_principal_name}.")
72
+
73
+ url = f"https://graph.microsoft.com/v1.0/users/{user_principal_name}/memberOf"
74
+ headers = get_headers(bearer_token)
75
+ response = make_graph_api_request(
76
+ method="GET",
77
+ url=url,
78
+ headers=headers,
79
+ context="get_user_direct_group_memberships"
80
+ )
81
+ if response is False:
82
+ logging.debug(f"Failed to fetch group memberships for {user_principal_name}.")
83
+ return False
84
+ logging.debug(f"Succeeded in fetching group memberships for {user_principal_name}.")
85
+ return response
@@ -0,0 +1,16 @@
1
+ Metadata-Version: 2.4
2
+ Name: microsoft_graph_helpers
3
+ Version: 0.1.0
4
+ Summary: Helper functions for interacting with Microsoft Graph API
5
+ Author: Lucas Krupinski
6
+ License: MIT
7
+ Requires-Python: >=3.12
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: requests>=2.32.5
10
+ Dynamic: requires-python
11
+
12
+ # Microsoft Graph Helpers
13
+
14
+ A Python module for calling various Microsoft Graph endpoints. Not nearly as comprehensive as Microsoft's official modules, but far lighter weight and with a minimal amount of dependencies.
15
+
16
+ Not comprehensive, only contains calls that I've needed to user in my other applets.
@@ -0,0 +1,15 @@
1
+ README.md
2
+ pyproject.toml
3
+ setup.py
4
+ microsoft_graph_helpers/__init__.py
5
+ microsoft_graph_helpers/api_token.py
6
+ microsoft_graph_helpers/core.py
7
+ microsoft_graph_helpers/email.py
8
+ microsoft_graph_helpers/groups.py
9
+ microsoft_graph_helpers/security.py
10
+ microsoft_graph_helpers/users.py
11
+ microsoft_graph_helpers.egg-info/PKG-INFO
12
+ microsoft_graph_helpers.egg-info/SOURCES.txt
13
+ microsoft_graph_helpers.egg-info/dependency_links.txt
14
+ microsoft_graph_helpers.egg-info/requires.txt
15
+ microsoft_graph_helpers.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ microsoft_graph_helpers
@@ -0,0 +1,18 @@
1
+ [project]
2
+ name = "microsoft_graph_helpers"
3
+ version = "0.1.0"
4
+ description = "Helper functions for interacting with Microsoft Graph API"
5
+ authors = [
6
+ { name = "Lucas Krupinski" }
7
+ ]
8
+ readme = "README.md"
9
+ license = { text = "MIT" }
10
+ requires-python = ">=3.12"
11
+
12
+ dependencies = [
13
+ "requests>=2.32.5"
14
+ ]
15
+
16
+ [build-system]
17
+ requires = ["setuptools>=42", "wheel"]
18
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,13 @@
1
+ from setuptools import setup, find_packages
2
+
3
+ setup(
4
+ name='microsoft_graph_helpers',
5
+ version='0.1.0',
6
+ description='Helper functions for interacting with Microsoft Graph API',
7
+ author='Lucas Krupinski',
8
+ packages=find_packages(),
9
+ install_requires=[
10
+ 'requests>=2.32.5'
11
+ ],
12
+ python_requires='>=3.12',
13
+ )