brynq-sdk-azure 3.0.3__py3-none-any.whl

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,3 @@
1
+ from .entra import Entra
2
+ from .azure_connection import AzureConnection
3
+ from .blob_storage import BlobStorage
@@ -0,0 +1,104 @@
1
+ """
2
+ See how-to on our confluence page for more details.
3
+ Use Azure python sdk to sync the files with Azure Files Share service.
4
+ The config file shall have a settings in a format like:
5
+ azure_config = {
6
+ 'azure_connection_string' : r'{the azure connection string}'
7
+ 'share_name' : "/sharename/",
8
+ 'parent_dir_path' : r"volume/data/" : ALAWAYS start with a test file, to make sure you don't mess up the other folders/files
9
+ }
10
+ """
11
+
12
+ import os
13
+ import sys
14
+ from azure.storage.fileshare import ShareClient
15
+
16
+ basedir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
17
+ sys.path.append(basedir)
18
+
19
+
20
+ class AzureConnection:
21
+ """
22
+ connection_string: the connection_string which functions as a token for the Azure connection
23
+ share_name: the share name in Azure
24
+ """
25
+ def __init__(self):
26
+ # This ugly fix is needed so the other packages can run without a config.py file
27
+ import config
28
+ self.connection_string = config.azure_config['azure_connection_string']
29
+ self.share_name = config.azure_config['share_name']
30
+ self.share_client = ShareClient.from_connection_string(self.connection_string, share_name=self.share_name)
31
+
32
+ def list_files_and_dirs(self, dir_client):
33
+ """
34
+ List all the files and folders under this directory_path.
35
+ :param dir_client: The connection to a specified directory
36
+ :return: two lists consist of files and subfolders separately
37
+ """
38
+ my_list = list(dir_client.list_directories_and_files())
39
+ subdir_list = [x['name'] for x in my_list if x['is_directory'] is True]
40
+ file_list = [x['name'] for x in my_list if x['is_directory'] is False]
41
+ return file_list, subdir_list
42
+
43
+ def create_directory(self, dir_path):
44
+ """
45
+ Create a ShareDirectoryClient from a connection string
46
+ :param dir_path: The directory_path
47
+ :return: a share_client which connects with the specified directory,
48
+ and a Flag to indicate if this directory exists before
49
+ """
50
+ dir_client = self.share_client.get_directory_client(directory_path=dir_path)
51
+ dir_already_existed = False
52
+ try:
53
+ dir_client.create_directory()
54
+ except:
55
+ dir_already_existed = True
56
+ return dir_client, dir_already_existed
57
+
58
+
59
+ def empty_folder(self, dir_client, delete_folder = False):
60
+ """
61
+ To empty a folder including all the subfolders and files within.
62
+ :param dir_client: The share to connect with this directory
63
+ :param delete_folder: To delete the folder as well. If yes then the whole folder will be removed, otherwise only remove the files.
64
+ """
65
+ file_list, subdir_list = self.list_files_and_dirs(dir_client)
66
+ if len(subdir_list)>0:
67
+ for subdir in subdir_list:
68
+ self.empty_folder(dir_client.get_subdirectory_client(subdir), delete_folder=True)
69
+ for file in file_list:
70
+ dir_client.delete_file(file)
71
+ if delete_folder:
72
+ dir_client.delete_directory()
73
+
74
+ def create_subdirectory_and_upload_file(self, parentdir_path, subdir_path, local_file_path, filename):
75
+ """
76
+ Create a subfolder and upload files to this folder
77
+ :param parentdir_path: The parent directory
78
+ :param subdir_path: the subfolder directory
79
+ :param local_file_path: local file directory from which to upload the files
80
+ :param filename: filename to be uploaded
81
+ :return:
82
+ """
83
+ _, _ = self.create_directory(parentdir_path)
84
+ dir_path = os.path.join(parentdir_path,subdir_path)
85
+ subdir, dir_already_existed = self.create_directory(dir_path)
86
+ if dir_already_existed:
87
+ self.empty_folder(subdir, delete_folder=False)
88
+ # Upload a file to the subdirectory
89
+ with open(os.path.join(local_file_path,filename), "rb") as source:
90
+ subdir.upload_file(file_name=filename, data=source)
91
+
92
+ def create_directory_and_upload_file(self, parentdir_path, local_file_path, filename):
93
+ """
94
+ Create a folder and upload files to this folder
95
+ :param parentdir_path: The parent directory
96
+ :param local_file_path: local file directory from which to upload the files
97
+ :param filename: filename to be uploaded
98
+ :return:
99
+ """
100
+ # Get the directory client
101
+ parentdir_dir, _ = self.create_directory(parentdir_path)
102
+ # Upload a file to the subdirectory
103
+ with open(os.path.join(local_file_path, filename), "rb") as source:
104
+ parentdir_dir.upload_file(file_name=filename, data=source)
@@ -0,0 +1,176 @@
1
+ from brynq_sdk_brynq import BrynQ
2
+ from azure.storage.blob import BlobServiceClient, BlobClient, ContainerClient, generate_account_sas, ResourceTypes, AccountSasPermissions
3
+ from typing import Union, List, Tuple, Literal, Optional
4
+ from datetime import datetime, timedelta
5
+
6
+
7
+ class BlobStorage(BrynQ):
8
+ def __init__(self, system_type: Optional[Literal['source', 'target']] = None):
9
+ super().__init__()
10
+ self.blob_service_client = self._get_authentication(system_type)
11
+
12
+ def _get_authentication(self, system_type):
13
+ credentials = self.interfaces.credentials.get(system='azure-blob-storage', system_type=system_type)
14
+ credentials = credentials.get('data')
15
+ storage_account_name = credentials['storage_account_name']
16
+ storage_account_key = credentials['storage_account_key']
17
+ sas_token = generate_account_sas(
18
+ account_name=storage_account_name,
19
+ account_key=storage_account_key,
20
+ resource_types=ResourceTypes(service=True, container=True, object=True),
21
+ permission=AccountSasPermissions(read=True, write=True, list=True, delete=True, add=True, create=True, update=True, process=True),
22
+ expiry=datetime.utcnow() + timedelta(hours=1)
23
+ )
24
+ blob_service_client = BlobServiceClient(
25
+ account_url=f"https://{storage_account_name}.blob.core.windows.net",
26
+ credential=sas_token
27
+ )
28
+
29
+ return blob_service_client
30
+
31
+ def get_containers(self):
32
+ all_containers = self.blob_service_client.list_containers(include_metadata=True)
33
+ container_list = []
34
+ for container in all_containers:
35
+ container_info = {
36
+ 'name': container.name,
37
+ 'last_modified': container.last_modified,
38
+ 'etag': container.etag,
39
+ 'lease_state': container.lease,
40
+ 'has_immutability_policy': container.has_immutability_policy,
41
+ 'has_legal_hold': container.has_legal_hold,
42
+ 'metadata': container.metadata
43
+ }
44
+ container_list.append(container_info)
45
+
46
+ return container_list
47
+
48
+ def get_container(self, container_name: str):
49
+ """
50
+ Get a container from the blob storage
51
+ """
52
+ container = self.blob_service_client.get_container_client(container_name)
53
+ return container
54
+
55
+ def create_container(self, container_name: str):
56
+ """
57
+ Create a container in the blob storage
58
+ """
59
+ response = self.blob_service_client.create_container(container_name)
60
+ return response
61
+
62
+ def update_container(self):
63
+ pass
64
+
65
+ def delete_container(self):
66
+ pass
67
+
68
+ def get_folders(self, container_name: str):
69
+ """
70
+ Retrieves a list of 'folders' in the specified container.
71
+ Since Azure Blob Storage uses a flat namespace, folders are simulated using prefixes.
72
+
73
+ :param container_name: The name of the container.
74
+ :return: A list of folder names.
75
+ """
76
+ container_client = self.get_container(container_name)
77
+ blobs_list = container_client.list_blobs()
78
+
79
+ folder_set = set()
80
+ for blob in blobs_list:
81
+ if '/' in blob.name:
82
+ folder = blob.name.split('/')[0]
83
+ folder_set.add(folder)
84
+ folders = list(folder_set)
85
+ return folders
86
+
87
+ def create_folder(self, container_name: str, folder_name: str):
88
+ """
89
+ Create a file with a 0 as content. Because the file is created, the folder is also created. After that the file and the folder are created,
90
+ delete the file so the folder will stay. According to the azure docs, it should be possible to create empty files, but this is not working.
91
+ """
92
+ # Split the url and add the container and folder name in between the url
93
+ original_url = self.blob_service_client.url.split('?')
94
+ url = f"{original_url[0]}/{container_name}/{folder_name}/empty_file?{original_url[1]}"
95
+ blob = BlobClient.from_blob_url(blob_url=url)
96
+
97
+ # Now create the file and delete it so the folder will stay
98
+ response = blob.upload_blob(b"0", blob_type='AppendBlob')
99
+ blob.delete_blob()
100
+ return response
101
+
102
+ def delete_folder(self, container_name: str, folder_name: str):
103
+ """
104
+ Deletes all the blobs (files) within a folder, effectively deleting the folder.
105
+ :param container_name: The name of the container.
106
+ :param folder_name: The name of the folder to delete.
107
+ """
108
+ container_client = self.get_container(container_name)
109
+ blobs = container_client.list_blobs(name_starts_with=f"{folder_name}/")
110
+ for blob in blobs:
111
+ blob_client = container_client.get_blob_client(blob)
112
+ blob_client.delete_blob()
113
+ return f"Deleted folder {folder_name} and all its contents."
114
+
115
+ def get_files(self, container_name: str, folder_name: str = ""):
116
+ """
117
+ Retrieves all files in a container, optionally filtered by folder.
118
+ :param container_name: The name of the container.
119
+ :param folder_name: The name of the folder (optional). If provided, only files in this folder will be listed.
120
+ :return: A list of file names in the container or folder.
121
+ """
122
+ container_client = self.get_container(container_name)
123
+ blobs_list = container_client.list_blobs(name_starts_with=f"{folder_name}/" if folder_name else "")
124
+
125
+ file_list = []
126
+ for blob in blobs_list:
127
+ if not blob.name.endswith('/'): # Exclude folder markers
128
+ file_list.append(blob.name)
129
+
130
+ return file_list
131
+
132
+ def upload_file(self, container_name: str, blob_name: str, file_path: str, overwrite: bool = False):
133
+ """
134
+ Uploads a single file to Azure Blob Storage.
135
+ :param container_name: The name of the container to upload to.
136
+ :param blob_name: The name of the blob (the file name in blob storage).
137
+ :param file_path: The local path to the file to upload.
138
+ :param overwrite: Whether to overwrite an existing blob. Default is False.
139
+ """
140
+ # Get the container client
141
+ container_client = self.get_container(container_name)
142
+
143
+ # Get the blob client
144
+ blob_client = container_client.get_blob_client(blob_name)
145
+
146
+ # Open the file and upload
147
+ with open(file_path, "rb") as data:
148
+ blob_client.upload_blob(data, overwrite=overwrite)
149
+
150
+ print(f"Successfully uploaded {file_path} to {blob_client.url}")
151
+ return blob_client.url
152
+
153
+ def upload_files(self, container_name: str, files: List[Tuple[str, str]], overwrite: bool = False):
154
+ """
155
+ Uploads multiple files to Azure Blob Storage.
156
+ :param container_name: The name of the container to upload to.
157
+ :param files: A list of tuples (blob_name, file_path), where blob_name is the name of the blob in storage, and file_path is the local file path.
158
+ :param overwrite: Whether to overwrite existing blobs. Default is False.
159
+ """
160
+ success = True
161
+ for blob_name, file_path in files:
162
+ result = self.upload_file(container_name, blob_name, file_path, overwrite=overwrite)
163
+ if result is None:
164
+ success = False
165
+ return success
166
+
167
+ def delete_file(self, container_name: str, blob_name: str):
168
+ """
169
+ Deletes a specific file from Azure Blob Storage.
170
+ :param container_name: The name of the container.
171
+ :param blob_name: The name of the blob (the file) to delete.
172
+ """
173
+ container_client = self.get_container(container_name)
174
+ blob_client = container_client.get_blob_client(blob_name)
175
+ blob_client.delete_blob()
176
+ return f"Deleted file {blob_name} from container {container_name}."
@@ -0,0 +1,348 @@
1
+ from brynq_sdk_brynq import BrynQ
2
+ import urllib.parse
3
+ import warnings
4
+ import requests
5
+ import random
6
+ import string
7
+ import json
8
+ import pandas as pd
9
+ from pandas import json_normalize
10
+ from msal import ConfidentialClientApplication
11
+ from typing import Union, List, Literal, Optional
12
+ import os
13
+
14
+
15
+ class Entra(BrynQ):
16
+
17
+ def __init__(self, system_type: Optional[Literal['source', 'target']] = None, debug: bool = False):
18
+ super().__init__()
19
+ self.headers = self.__get_headers(system_type)
20
+ self.endpoint = "https://graph.microsoft.com/v1.0"
21
+ self.timeout = 3600
22
+
23
+ def __get_headers(self, system_type):
24
+ credentials = self.interfaces.credentials.get(system='azure-entra-o-auth-2', system_type=system_type)
25
+ credentials_data = credentials.get('data')
26
+ if credentials.get("type") == 'custom':
27
+ tenant_id = credentials_data.get('tenant_id')
28
+ client_id = credentials_data.get('client_id')
29
+ client_secret = credentials_data.get('client_secret')
30
+ authority = f"https://login.microsoftonline.com/{tenant_id}"
31
+ # Create a ConfidentialClientApplication for authentication
32
+ app = ConfidentialClientApplication(
33
+ client_id,
34
+ authority=authority,
35
+ client_credential=client_secret,
36
+ )
37
+
38
+ # Get an access token for the Graph API
39
+ result = app.acquire_token_for_client(scopes=["https://graph.microsoft.com/.default"])
40
+ access_token = result.get('access_token')
41
+ elif credentials.get("type") == 'oauth2':
42
+ access_token = credentials_data.get('access_token')
43
+ else:
44
+ raise ValueError("The retrieved credentials are not part of the supported system types, please extend the SDK to support the specified type")
45
+
46
+ headers = {
47
+ 'Authorization': f"Bearer {access_token}",
48
+ 'Content-Type': 'application/json'
49
+ }
50
+
51
+ return headers
52
+
53
+ def __add_attribute_information(self, payload, custom_attributes):
54
+ # First get the official name of the custom attribute and all the other information
55
+ payload.update({"customSecurityAttributes": {}})
56
+ metadata = requests.get('https://graph.microsoft.com/v1.0/directory/customSecurityAttributeDefinitions', headers=self.headers, timeout=self.timeout).json()
57
+ # Now loop through the given metadata and add the corresponding metadata and the values itself to the payload
58
+ for attr, value in custom_attributes.items():
59
+ for meta in metadata["value"]:
60
+ if meta["name"] == attr:
61
+ attr_set = meta["attributeSet"]
62
+ attr_type = meta["type"]
63
+ is_collection = meta["isCollection"]
64
+ if attr_set not in payload["customSecurityAttributes"]:
65
+ payload["customSecurityAttributes"][attr_set] = {"@odata.type": "#microsoft.graph.customSecurityAttributeValue"}
66
+ # In case of an integer, the field type should be given as well
67
+ if attr_type == "Integer":
68
+ if is_collection:
69
+ payload["customSecurityAttributes"][attr_set][f"{attr}@odata.type"] = "#Collection(Int32)"
70
+ else:
71
+ payload["customSecurityAttributes"][attr_set][f"{attr}@odata.type"] = "#Int32"
72
+ payload["customSecurityAttributes"][attr_set][attr] = value
73
+ # In case of a boolean, only the value should be given, the field type itself is not relevant
74
+ elif attr_type == "Boolean":
75
+ payload["customSecurityAttributes"][attr_set][attr] = value
76
+ # In case of a string, the field type should be given if the field is a collection of values. If it's a single value, the field type is not relevant
77
+ else:
78
+ if is_collection:
79
+ payload["customSecurityAttributes"][attr_set][f"{attr}@odata.type"] = "#Collection(String)"
80
+ payload["customSecurityAttributes"][attr_set][attr] = value
81
+ return payload
82
+
83
+ def __generate_password(self):
84
+ special_characters = string.punctuation
85
+ digits = string.digits
86
+ uppercase_letters = string.ascii_uppercase
87
+ lowercase_letters = string.ascii_lowercase
88
+
89
+ # Create a pool of characters
90
+ pool = special_characters + digits + uppercase_letters + lowercase_letters
91
+
92
+ # Ensure at least one character of each type
93
+ password = random.choice(special_characters)
94
+ password += random.choice(digits)
95
+ password += random.choice(uppercase_letters)
96
+ password += random.choice(lowercase_letters)
97
+
98
+ # Fill the remaining length with random characters
99
+ password += ''.join(random.choice(pool) for _ in range(20 - 4))
100
+
101
+ # Shuffle the characters to make the password more random
102
+ password_list = list(password)
103
+ random.shuffle(password_list)
104
+ password = ''.join(password_list)
105
+
106
+ return password
107
+
108
+ def get_groups(self) -> pd.DataFrame:
109
+ """
110
+ Get all groups from Azure Entra
111
+ :return: pd.DataFrame with the groups
112
+ """
113
+ endpoint = "https://graph.microsoft.com/v1.0"
114
+ df = pd.DataFrame()
115
+ loop = True
116
+ url = f"{endpoint}/groups"
117
+ while loop:
118
+ response = requests.get(url, headers=self.headers, timeout=self.timeout)
119
+ groups = response.json()['value']
120
+ df_temp = pd.json_normalize(groups)
121
+ df = pd.concat([df, df_temp], ignore_index=True)
122
+ if '@odata.nextLink' in response.json():
123
+ url = response.json()['@odata.nextLink']
124
+ else:
125
+ loop = False
126
+ df = df.reset_index(drop=True)
127
+ return df
128
+
129
+ def get_group_members(self, group_id: str = '') -> pd.DataFrame:
130
+ """
131
+ Get all users from a group in Azure Entra
132
+ :param group_id: ID of the group. If no ID is given, all possible groups will be returned
133
+ :return: pd.DataFrame with the users
134
+ """
135
+ group_url = "https://graph.microsoft.com/v1.0/groups/"
136
+ df = pd.DataFrame()
137
+ while group_url:
138
+ graph_r = requests.get(group_url, headers=self.headers, timeout=self.timeout)
139
+ graph_json = graph_r.json()
140
+ groups = graph_json.get('value')
141
+ for group in groups:
142
+ print(f"Group ID: {group['id']}, Group Name: {group['displayName']}")
143
+ # Get users in each group
144
+ next_url_members = f"https://graph.microsoft.com/v1.0/groups/{group['id']}/members"
145
+ while next_url_members:
146
+ members_r = requests.get(next_url_members, headers=self.headers, timeout=self.timeout)
147
+ members_json = members_r.json()
148
+ members = members_json.get('value')
149
+ df_temp = pd.json_normalize(members)
150
+ if len(df_temp) > 0:
151
+ df_temp['group_id'] = group['id']
152
+ df_temp['group'] = group['displayName']
153
+ df_temp.rename(columns={'id': 'user_id'}, inplace=True)
154
+ df = pd.concat([df, df_temp], ignore_index=True)
155
+ next_url_members = members_json.get('@odata.nextLink')
156
+ group_url = graph_json.get('@odata.nextLink')
157
+
158
+ df = df.reset_index(drop=True)
159
+ return df
160
+
161
+ def create_group(self, name: str = '', description: str = '', mail_enabled: bool = False, mail_nickname: str = '', security_enabled: bool = True):
162
+ """
163
+ Create a new group in Azure Entra
164
+ :param name: Name of the group
165
+ :param description: Description of the group
166
+ :param mail_enabled: Is the group mail enabled?
167
+ :param mail_nickname: Mail nickname of the group
168
+ :param security_enabled: Is the group security enabled?
169
+ :return: Response of the request
170
+ """
171
+ endpoint = "https://graph.microsoft.com/v1.0/groups"
172
+ payload = {
173
+ "displayName": f"{name}",
174
+ "description": f"{description}",
175
+ "mailEnabled": mail_enabled,
176
+ "mailNickname": f"{mail_nickname}",
177
+ "securityEnabled": security_enabled
178
+ }
179
+ response = requests.post(endpoint, headers=self.headers, json=payload, timeout=self.timeout)
180
+ return response
181
+
182
+ def update_group(self, id: int, name: str = '', description: str = '', mail_enabled: bool = False, mail_nickname: str = '', security_enabled: bool = True):
183
+ """
184
+ Create a new group in Azure Entra
185
+ :param id: ID of the group
186
+ :param name: Name of the group
187
+ :param description: Description of the group
188
+ :param mail_enabled: Is the group mail enabled?
189
+ :param mail_nickname: Mail nickname of the group
190
+ :param security_enabled: Is the group security enabled?
191
+ :return: Response of the request
192
+ """
193
+ endpoint = f"https://graph.microsoft.com/v1.0/groups/{id}"
194
+ payload = {
195
+ "displayName": f"{name}",
196
+ "description": f"{description}",
197
+ "mailEnabled": mail_enabled,
198
+ "mailNickname": f"{mail_nickname}",
199
+ "securityEnabled": security_enabled
200
+ }
201
+ response = requests.patch(endpoint, headers=self.headers, json=payload, timeout=self.timeout)
202
+ return response
203
+
204
+ def delete_group(self, group_id):
205
+ """
206
+ Delete a group in Azure Entra
207
+ :param group_id: ID of the group
208
+ :return: Response of the request
209
+ """
210
+ endpoint = f"https://graph.microsoft.com/v1.0/groups/{group_id}"
211
+ response = requests.delete(endpoint, headers=self.headers, timeout=self.timeout)
212
+ return response
213
+
214
+ def get_users(self, extra_fields: list = [], custom_attributes: bool = False, expand: str = '', expand_select: str = '') -> pd.DataFrame:
215
+ """
216
+ Get all users from Azure Entra
217
+ :param extra_fields: Besided the default fields, you can add extra fields to the request. Put them in a list
218
+ :param custom_attributes: Get the custom attributes of the users. If True, all the custom attributes will be returned
219
+ :return: pd.DataFrame with the users
220
+ """
221
+ fields = ['businessPhones', 'displayName', 'givenName', 'id', 'jobTitle', 'mail', 'mobilePhone',
222
+ 'officeLocation', 'preferredLanguage', 'surname', 'userPrincipalName'] + extra_fields
223
+ fields = ','.join(fields)
224
+ endpoint = f"https://graph.microsoft.com/v1.0/users?$select={fields}"
225
+ if custom_attributes:
226
+ endpoint = f"https://graph.microsoft.com/beta/users?$select={fields},customSecurityAttributes"
227
+ # Adding expand and select parameters if provided
228
+ if expand:
229
+ if expand_select:
230
+ endpoint += f",&$expand={expand}($select={expand_select})"
231
+ else:
232
+ endpoint += f",&$expand={expand}"
233
+
234
+ df = pd.DataFrame()
235
+ while endpoint:
236
+ response = requests.get(endpoint, headers=self.headers, timeout=self.timeout)
237
+ endpoint = response.json().get('@odata.nextLink')
238
+ data = response.json().get('value')
239
+ df_temp = json_normalize(data, sep='.')
240
+ df_temp = df_temp.drop([col for col in df_temp.columns if 'odata.type' in col], axis=1)
241
+ df = pd.concat([df, df_temp], ignore_index=True)
242
+ df = df.reset_index(drop=True)
243
+ return df
244
+
245
+ def create_user(self, account_enabled=True, display_name='', mail_nickname='', user_principal_name='', password='', force_change_password_next_sign_in=False, extra_fields={}, custom_attributes={}):
246
+ """
247
+ Create a new user in Azure Entra
248
+ :param account_enabled: Is the account enabled? By default True
249
+ :param display_name: Display name of the user
250
+ :param mail_nickname: Mail nickname of the user (the part before the @)
251
+ :param user_principal_name: User principal name of the user
252
+ :param password: Password of the user. If no password is given, a random password will be generated
253
+ :param force_change_password_next_sign_in: Force the user to change the password on the next sign in. By default False
254
+ :param extra_fields: Extra fields you want to add to the user. Put them in a dictionary
255
+ :param custom_attributes: A dictionary with the name of the custom attribute and the value. It could be multiple custom attributes
256
+ """
257
+ # Custom attributes are only available in the beta version of the API
258
+ endpoint = 'https://graph.microsoft.com/beta/users' if custom_attributes else 'https://graph.microsoft.com/v1.0/users'
259
+ if password == '':
260
+ password = self.__generate_password()
261
+
262
+ payload = {
263
+ "accountEnabled": account_enabled,
264
+ "displayName": f"{display_name}",
265
+ "mailNickname": f"{mail_nickname}",
266
+ "userPrincipalName": user_principal_name,
267
+ "passwordProfile": {
268
+ "forceChangePasswordNextSignIn": force_change_password_next_sign_in,
269
+ "password": f"{password}"
270
+ },
271
+ }
272
+ payload.update(extra_fields)
273
+
274
+ # If there are any custom attributes, add them to the payload. But since the endpoint needs extra metadata, we need to do some extra work
275
+ if len(custom_attributes) > 0:
276
+ payload = self.__add_attribute_information(payload, custom_attributes)
277
+ response = requests.post(endpoint, headers=self.headers, json=payload, timeout=self.timeout)
278
+ return response
279
+
280
+ def update_user(self, user_id, fields_to_update: dict = {}, custom_attributes: dict = {}, update_password: bool = False):
281
+ """
282
+ Update a user in Azure Entra
283
+ :param user_id: The Azure AD ID of the user
284
+ :param fields_to_update: A dictionary with the fields you want to update. Don't put the custom attributes in this dictionary
285
+ :param custom_attributes: A dictionary with the name of the custom attribute and the value. It could be multiple custom attributes
286
+ :param update_password: If True, the password will be updated with a random value. If False, the password will not be updated
287
+ """
288
+ endpoint = f'https://graph.microsoft.com/beta/users/{user_id}' if len(custom_attributes) > 0 else f'https://graph.microsoft.com/v1.0/users/{user_id}'
289
+ payload = fields_to_update
290
+ if update_password:
291
+ password = self.__generate_password()
292
+ payload.update({"passwordProfile": {
293
+ "forceChangePasswordNextSignIn": False,
294
+ "password": f"{password}"
295
+ }})
296
+ if len(custom_attributes) > 0:
297
+ payload = self.__add_attribute_information(payload, custom_attributes)
298
+ response = requests.patch(endpoint, headers=self.headers, json=payload, timeout=self.timeout)
299
+ return response
300
+
301
+ def delete_user(self, user_id, delete=False):
302
+ """
303
+ Delete (soft or hard) a user from Azure Entra
304
+ :param user_id: The Azure AD ID of the user
305
+ :param delete: If True, the user will be deleted permanently. If False, the user will be soft deleted
306
+ """
307
+ endpoint = f"https://graph.microsoft.com/v1.0/users/{user_id}"
308
+ if delete:
309
+ response = requests.delete(endpoint, headers=self.headers, timeout=self.timeout)
310
+ else:
311
+ payload = {"accountEnabled": False}
312
+ response = requests.patch(endpoint, headers=self.headers, data=json.dumps(payload), timeout=self.timeout)
313
+ return response
314
+
315
+ def assign_user_to_group(self, user_id, group_id):
316
+ """
317
+ Assign a user to a group
318
+ :param user_id: The Azure AD ID of the user
319
+ :param group_id: The Azure AD ID of the group
320
+ return: response
321
+ """
322
+ url = f"https://graph.microsoft.com/v1.0/groups/{group_id}/members/$ref"
323
+ data = {"@odata.id": f"https://graph.microsoft.com/v1.0/directoryObjects/{user_id}"}
324
+ response = requests.post(url, headers=self.headers, data=json.dumps(data), timeout=self.timeout)
325
+ return response
326
+
327
+ def update_manager(self, user_id, manager_id):
328
+ """
329
+ Update the manager of a user
330
+ :param user_id: The Azure AD ID of the user
331
+ :param manager_id: The Azure AD ID of the manager
332
+ return: response
333
+ """
334
+ url = f"https://graph.microsoft.com/v1.0/users/{user_id}/manager/$ref"
335
+ content = {f"@odata.id": f"https://graph.microsoft.com/v1.0/users/{manager_id}"}
336
+ response = requests.put(url, headers=self.headers, data=json.dumps(content), timeout=self.timeout)
337
+ return response
338
+
339
+ def remove_user_from_group(self, user_id, group_id):
340
+ """
341
+ Remove a user from a group
342
+ :param user_id: The Azure AD ID of the user
343
+ :param group_id: The Azure AD ID of the group
344
+ return: response
345
+ """
346
+ url = f"https://graph.microsoft.com/v1.0/groups/{group_id}/members/{user_id}/$ref"
347
+ response = requests.delete(url, headers=self.headers, timeout=self.timeout)
348
+ return response
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.4
2
+ Name: brynq_sdk_azure
3
+ Version: 3.0.3
4
+ Summary: Azure wrapper from BrynQ
5
+ Author: BrynQ
6
+ Author-email: support@brynq.com
7
+ License: BrynQ License
8
+ Requires-Dist: brynq-sdk-brynq<5,>=4
9
+ Requires-Dist: azure-storage-file-share>=12.6.0
10
+ Requires-Dist: azure-storage-blob>=12.16.0
11
+ Requires-Dist: msal==1.22.0
12
+ Dynamic: author
13
+ Dynamic: author-email
14
+ Dynamic: description
15
+ Dynamic: license
16
+ Dynamic: requires-dist
17
+ Dynamic: summary
18
+
19
+ Azure wrapper from BrynQ
@@ -0,0 +1,8 @@
1
+ brynq_sdk_azure/__init__.py,sha256=XYZRt17_SuLmTJo6kGG9s7NvtOTjLbShRuie7Va9C_4,109
2
+ brynq_sdk_azure/azure_connection.py,sha256=PukXpOLCCwbuRO_pDwwezfl_JjSP91oFsC55UO5Gex0,4657
3
+ brynq_sdk_azure/blob_storage.py,sha256=YTe1zDZvdalvCciwW8unwbDZIlGEzcHUZTh__Ub88Hg,7830
4
+ brynq_sdk_azure/entra.py,sha256=5qsvrxuHipYPS2lg42Kyn70pfs7V1LNRDdz2NIpJ1tU,17324
5
+ brynq_sdk_azure-3.0.3.dist-info/METADATA,sha256=4oKuNPdmAWc1cLKPNQmK27I1xpKZEh-ZsTCeUr5OIqE,460
6
+ brynq_sdk_azure-3.0.3.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
7
+ brynq_sdk_azure-3.0.3.dist-info/top_level.txt,sha256=AkGDvcXu0eRp1oApJBx26dzC_CYW8kxXxGgQUSutmAk,16
8
+ brynq_sdk_azure-3.0.3.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ brynq_sdk_azure