tgwrap 0.8.12__py3-none-any.whl → 0.11.2__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.
tgwrap/inspector.py ADDED
@@ -0,0 +1,438 @@
1
+ """Verify a given Azure environment based on some verification config file"""
2
+
3
+ import os
4
+ import yaml
5
+ import requests
6
+ import json
7
+ import re
8
+
9
+ from typing import Tuple, Dict, List
10
+ from enum import Enum
11
+ from jinja2 import Template
12
+
13
+ from azure.identity import DefaultAzureCredential
14
+ from azure.core.exceptions import ClientAuthenticationError
15
+ from azure.mgmt.authorization import AuthorizationManagementClient
16
+
17
+ from .printer import Printer
18
+
19
+ class ResourceInspectionStatus(Enum):
20
+ OK = "Successfully inspected the resource"
21
+ NOK = "Resource does not have the right status"
22
+ NEX = "Resource does not exist"
23
+ NS = "Resource type is not supported"
24
+
25
+ class RoleAssignmentInspectionStatus(Enum):
26
+ OK = "Successfully inspected the role assignments"
27
+ NC = "Role assignments not checked"
28
+ NOK = "(Some) role assignments missing"
29
+
30
+ class AzureInspector():
31
+ _printer = None
32
+ _subscription_id = None
33
+ _domain = None
34
+ _substack = None
35
+ _stage = None
36
+ _config = {}
37
+ _credential = None
38
+ _client = None
39
+ _graph_token = None
40
+ _mngt_token = None
41
+ _role_definitions = {}
42
+ _result = {}
43
+
44
+ @property
45
+ def printer(self):
46
+ return self._printer
47
+
48
+ @property
49
+ def subscription_id(self):
50
+ return self._subscription_id
51
+
52
+ @property
53
+ def domain(self):
54
+ return self._domain
55
+
56
+ @property
57
+ def substack(self):
58
+ return self._substack
59
+
60
+ @property
61
+ def stage(self):
62
+ return self._stage
63
+
64
+ @property
65
+ def config(self):
66
+ return self._config
67
+
68
+ @property
69
+ def credential(self):
70
+ return self._credential
71
+
72
+ @property
73
+ def client(self):
74
+ return self._client
75
+
76
+ @property
77
+ def graph_token(self):
78
+ return self._graph_token
79
+
80
+ @property
81
+ def mngt_token(self):
82
+ return self._mngt_token
83
+
84
+ @property
85
+ def role_definitions(self):
86
+ return self._role_definitions
87
+
88
+ @property
89
+ def result(self):
90
+ return self._result
91
+
92
+ @result.setter
93
+ def result(self, value):
94
+ self._result = value
95
+
96
+ def __init__(self, subscription_id:str, domain:str, substack:str, stage:str, config_file:str, verbose:bool, managed_identity_client_id:str = None):
97
+ self._printer = Printer(verbose)
98
+ self._subscription_id = subscription_id
99
+ self._domain = domain
100
+ self._substack = substack
101
+ self._stage = stage
102
+
103
+ # load the config file
104
+ self._config = self._load_config(config_file)
105
+
106
+ # get a credential
107
+ self._credential = DefaultAzureCredential(
108
+ # if you are using a user-assigned identity, the client id must be specified here!
109
+ managed_identity_client_id = managed_identity_client_id,
110
+ )
111
+
112
+ self._client = AuthorizationManagementClient(
113
+ credential=self.credential,
114
+ subscription_id=self.subscription_id,
115
+ )
116
+
117
+ # Retrieve all role definitions
118
+ self.printer.verbose('Retrieve all role definitions')
119
+ role_definitions_iter = self.client.role_definitions.list(
120
+ scope=f'/subscriptions/{self.subscription_id}'
121
+ )
122
+ for rd in role_definitions_iter:
123
+ self._role_definitions[rd.role_name.lower()] = rd.name
124
+
125
+ def _get_value_from_dict(self, data:str, property:str) -> str:
126
+ """Get a value from a dict with a string that indicates the hierarchy with a dot"""
127
+
128
+ keys = property.split('.')
129
+ value = data
130
+ for key in keys:
131
+ if value:
132
+ value = value.get(key)
133
+
134
+ return value
135
+
136
+ def _load_config(self, config_file:str) -> Dict:
137
+ with open(config_file, 'r') as file:
138
+ try:
139
+ data = yaml.safe_load(file)
140
+ return data
141
+ except yaml.YAMLError as exc:
142
+ self.printer.error(f"Error loading YAML file: {exc}")
143
+ return None
144
+
145
+ def _load_yaml_template(self, template_file:str, inputs:Dict) -> Dict:
146
+ with open(template_file, 'r') as file:
147
+ try:
148
+ template_content = file.read()
149
+ template = Template(template_content)
150
+ rendered_yaml = template.render(inputs)
151
+
152
+ data = yaml.safe_load(rendered_yaml)
153
+ return data
154
+ except yaml.YAMLError as exc:
155
+ self.printer.error(f"Error loading YAML template file: {exc}")
156
+ return None
157
+
158
+ def _invoke_api(self, url:str, token:str, method:str='get', data:Dict=None) -> Dict:
159
+ """Invoke the Azure API"""
160
+
161
+ self.printer.verbose(f'Invoke {method.upper()} on {url}')
162
+ headers = {
163
+ 'Authorization': f'Bearer {token}',
164
+ 'Content-Type': 'application/json'
165
+ }
166
+ resp = None
167
+ try:
168
+ if method.lower() == 'get':
169
+ resp = requests.get(url, headers=headers)
170
+ elif method.lower() == 'post':
171
+ resp = requests.post(url, headers=headers, json=data)
172
+ elif method.lower() == 'delete':
173
+ resp = requests.delete(url, headers=headers)
174
+ else:
175
+ raise ValueError(f'Method {method} not recognised')
176
+
177
+ resp.raise_for_status()
178
+ return resp.json()
179
+ except requests.exceptions.HTTPError as e:
180
+ self.printer.verbose(f'Error occurred:\n{e.response.text}')
181
+ return None
182
+
183
+ def inspect(self):
184
+ try:
185
+ self._graph_token = self.credential.get_token('https://graph.microsoft.com/.default').token
186
+ self._mngt_token = self.credential.get_token('https://management.azure.com/.default').token
187
+ except ClientAuthenticationError as e:
188
+ self.printer.error(
189
+ f'Could not retrieve an azure token, are you logged in?',
190
+ print_line_before=True,
191
+ print_line_after=True,
192
+ )
193
+ raise (e)
194
+
195
+ self.result = {}
196
+
197
+ try:
198
+ # read the config
199
+ location_code = self.config['location'].get('code', 'westeurope')
200
+ location_full = self.config['location'].get('full', 'West Europe')
201
+ resources = self.config.get('resources', [])
202
+ groups = self.config.get('entra_id_groups', {})
203
+
204
+ # read the map that determines how to test a particular resource type
205
+ resource_types = self._load_yaml_template(
206
+ template_file=os.path.join(os.path.dirname(__file__), 'inspector-resources-template.yml'),
207
+ inputs={
208
+ 'location_code': location_code,
209
+ 'location_full': location_full,
210
+ },
211
+ )
212
+
213
+ # first check the groups, this will also enrich the map with the IDs of the actual groups
214
+ # build a list
215
+ groups_to_verify = []
216
+ for role, name in groups.items():
217
+ groups_to_verify.append({
218
+ 'identifier': name.format(
219
+ domain=self.domain,
220
+ substack=self.substack,
221
+ stage=self.stage,
222
+ ),
223
+ 'type': 'entra_id_group',
224
+ 'role': role,
225
+ })
226
+ # and verify
227
+ groups = self.inspect_resources(
228
+ resources=groups_to_verify,
229
+ resource_types=resource_types,
230
+ groups=groups,
231
+ )
232
+
233
+ # now inspect the resources
234
+ self.inspect_resources(
235
+ resources=resources,
236
+ resource_types=resource_types,
237
+ groups=groups,
238
+ )
239
+
240
+ return self.result
241
+ finally:
242
+ # ensure new messages are printed on a new line, as the progress indicator does not create a new line
243
+ self.printer.normal('')
244
+
245
+ def inspect_resources(self, resources:List, resource_types:Dict, groups:Dict) -> Dict:
246
+ for resource in resources:
247
+ self.printer.progress_indicator()
248
+
249
+ # get some identifiers from the config and replace with real values
250
+ identifier = resource['identifier'].format(
251
+ subscription_id=self.subscription_id,
252
+ domain=self.domain,
253
+ substack=self.substack,
254
+ stage=self.stage,
255
+ )
256
+ # it might be that we have an alternative id, as sometimes these are shortened because of length restrictions
257
+ alternative_ids = []
258
+ for id in resource.get('alternative_ids', []):
259
+ alternative_ids.append(
260
+ id.format(
261
+ subscription_id=self.subscription_id,
262
+ domain=self.domain,
263
+ substack=self.substack,
264
+ stage=self.stage,
265
+ )
266
+ )
267
+
268
+ resource_group = resource.get('resource_group', '').format(
269
+ domain=self.domain,
270
+ substack=self.substack,
271
+ stage=self.stage,
272
+ )
273
+
274
+ type = resource['type']
275
+ self.result[identifier] = {
276
+ "type": type,
277
+ }
278
+ this_result = self.result[identifier]
279
+
280
+ self.printer.verbose(f'Inspect {identifier} ({resource_group})')
281
+
282
+ # now we can start inspecting these
283
+ if type in resource_types:
284
+ resource_type = resource_types[type]
285
+ url = resource_type['url'].format(
286
+ subscription_id=self.subscription_id,
287
+ resource_group=resource_group,
288
+ name=identifier,
289
+ )
290
+
291
+ # which token to use?
292
+ graph_api = False
293
+ if 'graph.microsoft.com' in url:
294
+ graph_api = True
295
+ token = self.graph_token
296
+ elif 'management.azure.com' in url:
297
+ token = self.mngt_token
298
+ else:
299
+ self.printer.error(f'Do not have token for url: {url}')
300
+ break
301
+
302
+ resp = self._invoke_api(url=url, token=token)
303
+
304
+ # if we don't get anything back, try alternative ids (if we have some)
305
+ if (not resp or (graph_api and len(resp.get('value', [])) == 0)):
306
+ for id in alternative_ids:
307
+ resp = self._invoke_api(url=url, token=token)
308
+ url = resource_type['url'].format(
309
+ subscription_id=self.subscription_id,
310
+ resource_group=resource_group,
311
+ name=id,
312
+ )
313
+ resp = self._invoke_api(url=url, token=token)
314
+
315
+ # now check if we have something, we stop at first hit
316
+ if (not graph_api and resp) or (graph_api and len(resp.get('value', [])) > 0):
317
+ identifier = id
318
+ break
319
+
320
+ if not resp or (graph_api and len(resp.get('value', [])) == 0):
321
+ status = ResourceInspectionStatus.NEX.name
322
+ if resource_group:
323
+ msg = f"Resource {identifier} (in {resource_group}) of type {type} not found"
324
+ else:
325
+ msg = f"Resource {identifier} of type {type} not found"
326
+ ra_status = RoleAssignmentInspectionStatus.NC.name
327
+ ra_msg = ''
328
+ else:
329
+ self.printer.verbose(json.dumps(resp, indent=2))
330
+
331
+ status = ResourceInspectionStatus.OK.name # we're innocent until proven otherwise
332
+ msg = ""
333
+ for property, expected_value in resource_type['properties'].items():
334
+ property_value = self._get_value_from_dict(data=resp, property=property)
335
+ if isinstance(property_value, str) and not re.match(expected_value, property_value):
336
+ status = ResourceInspectionStatus.NOK.name
337
+ msg = msg + f"Property {property} has value {property_value}, expected regex {expected_value}"
338
+ elif isinstance(property_value, bool) and not property_value:
339
+ status = ResourceInspectionStatus.NOK.name
340
+ msg = msg + f"Property {property} has value {property_value}, expected boolean {expected_value}"
341
+
342
+ # set the default status that we haven't check the role assignments
343
+ ra_status = RoleAssignmentInspectionStatus.NC.name
344
+ ra_msg = 'Role assignments not checked'
345
+
346
+ # if this is an entra id group, we store the object id
347
+ if type == 'entra_id_group':
348
+ role = resource['role']
349
+ id = resp['value'][0]['id']
350
+ groups[role] = {
351
+ 'id': id,
352
+ 'name': identifier,
353
+ }
354
+ elif 'role_assignments' in resource:
355
+
356
+ ra_status, ra_msg = self.check_role_assignments(
357
+ resource=resource,
358
+ groups=groups,
359
+ url=url,
360
+ )
361
+
362
+ if not msg:
363
+ if resource_group:
364
+ msg = f"Resource {identifier} (in {resource_group}) of type {type} OK"
365
+ else:
366
+ msg = f"Resource {identifier} of type {type} OK"
367
+ if not ra_msg and ra_status == RoleAssignmentInspectionStatus.OK.name:
368
+ ra_msg = f"Role Assignments for {identifier} (in {resource_group}) of type {type} are OK"
369
+
370
+ this_result["inspect_status_code"] = status
371
+ this_result["inspect_status"] = ResourceInspectionStatus[status].value
372
+ this_result["inspect_message"] = msg
373
+
374
+ if not type == 'entra_id_group':
375
+ this_result["rbac_assignment_status_code"] = ra_status
376
+ this_result["rbac_assignment_status"] = RoleAssignmentInspectionStatus[ra_status].value
377
+ if ra_msg:
378
+ this_result["rbac_assignment_message"] = ra_msg
379
+ else:
380
+ self.printer.error(f'\nResource type {type} is not configured in tgwrap!')
381
+ this_result["inspect_status_code"] = ResourceInspectionStatus.NS.name
382
+ this_result["inspect_status"] = ResourceInspectionStatus.NS.value
383
+ this_result["inspect_message"] = f'Resource type {type} is not supported'
384
+
385
+ # the groups dict is updated with the IDs of the actual groups
386
+ return groups
387
+
388
+ def check_role_assignments(self, resource:Dict, groups:Dict, url:str):
389
+
390
+ status = RoleAssignmentInspectionStatus.NC.name
391
+ msg = ''
392
+
393
+ # extract the scope from the url
394
+ base_url=url.split('?')[0].rstrip('/')
395
+ scope = base_url.replace('https://management.azure.com', '')
396
+ self.printer.verbose('\nGet permissions over scope: ', scope)
397
+
398
+ # first get the role assignments for each (unique) principals
399
+ principals = set()
400
+ for ra in resource['role_assignments']:
401
+ principals.update(ra.keys())
402
+
403
+ principals = list(principals)
404
+ principal_assignments = {} # here we collect all assignments for this scope per principal
405
+ for p in principals:
406
+ principal_assignments[p] = []
407
+
408
+ if not p in groups:
409
+ self.printer.error(f'\nCannot find the group {p} in groups: {groups}')
410
+ continue
411
+ elif not 'id' in groups[p]:
412
+ self.printer.error(f'\nCannot find the id for group {p}: {groups[p]}')
413
+ continue
414
+
415
+ principal_id = groups[p]['id']
416
+ assignments = self.client.role_assignments.list_for_scope(
417
+ scope=scope,
418
+ filter=f"principalId eq '{principal_id}'",
419
+ )
420
+
421
+ # get the IDs of all roles assigned for the given scope
422
+ for a in assignments:
423
+ principal_assignments[p].append(a.role_definition_id.split('/')[-1]) # we only want the guid of the role definition
424
+
425
+ # assume the role assignments will be fine
426
+ status = RoleAssignmentInspectionStatus.OK.name
427
+ for role in resource['role_assignments']:
428
+ principal = list(role.keys())[0]
429
+ role = list(role.values())[0]
430
+
431
+ if role.lower() not in self.role_definitions:
432
+ raise ValueError(f"Role: '{role}' is not found.")
433
+
434
+ if (self.role_definitions[role.lower()] not in principal_assignments[principal]):
435
+ status = RoleAssignmentInspectionStatus.NOK.name
436
+ msg = msg + f'Principal {principal} has NOT role {role} assigned; '
437
+
438
+ return status, msg