cartography 0.111.0rc1__py3-none-any.whl → 0.112.0__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.

Potentially problematic release.


This version of cartography might be problematic. Click here for more details.

Files changed (68) hide show
  1. cartography/_version.py +2 -2
  2. cartography/cli.py +57 -0
  3. cartography/config.py +24 -0
  4. cartography/data/indexes.cypher +0 -2
  5. cartography/data/jobs/analysis/keycloak_inheritance.json +30 -0
  6. cartography/intel/aws/apigateway.py +128 -17
  7. cartography/intel/aws/ec2/instances.py +3 -1
  8. cartography/intel/aws/ec2/network_interfaces.py +1 -1
  9. cartography/intel/aws/ec2/vpc_peerings.py +262 -125
  10. cartography/intel/azure/__init__.py +35 -32
  11. cartography/intel/azure/subscription.py +2 -2
  12. cartography/intel/azure/tenant.py +39 -30
  13. cartography/intel/azure/util/credentials.py +49 -174
  14. cartography/intel/entra/__init__.py +47 -1
  15. cartography/intel/entra/applications.py +220 -170
  16. cartography/intel/entra/groups.py +41 -22
  17. cartography/intel/entra/ou.py +28 -20
  18. cartography/intel/entra/users.py +24 -18
  19. cartography/intel/gcp/__init__.py +25 -8
  20. cartography/intel/gcp/compute.py +47 -12
  21. cartography/intel/github/repos.py +19 -10
  22. cartography/intel/github/util.py +12 -0
  23. cartography/intel/keycloak/__init__.py +153 -0
  24. cartography/intel/keycloak/authenticationexecutions.py +322 -0
  25. cartography/intel/keycloak/authenticationflows.py +77 -0
  26. cartography/intel/keycloak/clients.py +187 -0
  27. cartography/intel/keycloak/groups.py +126 -0
  28. cartography/intel/keycloak/identityproviders.py +94 -0
  29. cartography/intel/keycloak/organizations.py +163 -0
  30. cartography/intel/keycloak/realms.py +61 -0
  31. cartography/intel/keycloak/roles.py +202 -0
  32. cartography/intel/keycloak/scopes.py +73 -0
  33. cartography/intel/keycloak/users.py +70 -0
  34. cartography/intel/keycloak/util.py +47 -0
  35. cartography/intel/kubernetes/__init__.py +26 -0
  36. cartography/intel/kubernetes/eks.py +402 -0
  37. cartography/intel/kubernetes/rbac.py +133 -0
  38. cartography/models/aws/apigateway/apigatewayintegration.py +79 -0
  39. cartography/models/aws/apigateway/apigatewaymethod.py +74 -0
  40. cartography/models/aws/ec2/vpc_peering.py +157 -0
  41. cartography/models/azure/principal.py +44 -0
  42. cartography/models/azure/tenant.py +20 -0
  43. cartography/models/keycloak/__init__.py +0 -0
  44. cartography/models/keycloak/authenticationexecution.py +160 -0
  45. cartography/models/keycloak/authenticationflow.py +54 -0
  46. cartography/models/keycloak/client.py +177 -0
  47. cartography/models/keycloak/group.py +101 -0
  48. cartography/models/keycloak/identityprovider.py +89 -0
  49. cartography/models/keycloak/organization.py +116 -0
  50. cartography/models/keycloak/organizationdomain.py +73 -0
  51. cartography/models/keycloak/realm.py +173 -0
  52. cartography/models/keycloak/role.py +126 -0
  53. cartography/models/keycloak/scope.py +73 -0
  54. cartography/models/keycloak/user.py +51 -0
  55. cartography/models/kubernetes/clusterrolebindings.py +40 -0
  56. cartography/models/kubernetes/groups.py +107 -0
  57. cartography/models/kubernetes/oidc.py +51 -0
  58. cartography/models/kubernetes/rolebindings.py +40 -0
  59. cartography/models/kubernetes/users.py +105 -0
  60. cartography/sync.py +2 -0
  61. cartography/util.py +10 -0
  62. {cartography-0.111.0rc1.dist-info → cartography-0.112.0.dist-info}/METADATA +9 -5
  63. {cartography-0.111.0rc1.dist-info → cartography-0.112.0.dist-info}/RECORD +67 -34
  64. cartography/data/jobs/cleanup/aws_import_vpc_peering_cleanup.json +0 -45
  65. {cartography-0.111.0rc1.dist-info → cartography-0.112.0.dist-info}/WHEEL +0 -0
  66. {cartography-0.111.0rc1.dist-info → cartography-0.112.0.dist-info}/entry_points.txt +0 -0
  67. {cartography-0.111.0rc1.dist-info → cartography-0.112.0.dist-info}/licenses/LICENSE +0 -0
  68. {cartography-0.111.0rc1.dist-info → cartography-0.112.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,322 @@
1
+ import logging
2
+ from collections import OrderedDict
3
+ from typing import Any
4
+ from urllib.parse import quote
5
+
6
+ import neo4j
7
+ import requests
8
+
9
+ from cartography.client.core.tx import load
10
+ from cartography.client.core.tx import load_matchlinks
11
+ from cartography.graph.job import GraphJob
12
+ from cartography.models.keycloak.authenticationexecution import (
13
+ ExecutionToExecutionMatchLink,
14
+ )
15
+ from cartography.models.keycloak.authenticationexecution import ExecutionToFlowMatchLink
16
+ from cartography.models.keycloak.authenticationexecution import (
17
+ KeycloakAuthenticationExecutionSchema,
18
+ )
19
+ from cartography.util import timeit
20
+
21
+ logger = logging.getLogger(__name__)
22
+ # Connect and read timeouts of 60 seconds each; see https://requests.readthedocs.io/en/master/user/advanced/#timeouts
23
+ _TIMEOUT = (60, 60)
24
+
25
+
26
+ @timeit
27
+ def sync(
28
+ neo4j_session: neo4j.Session,
29
+ api_session: requests.Session,
30
+ base_url: str,
31
+ common_job_parameters: dict[str, Any],
32
+ flow_aliases: list[str],
33
+ ) -> None:
34
+ exec_by_flow = get(
35
+ api_session,
36
+ base_url,
37
+ common_job_parameters["REALM"],
38
+ flow_aliases,
39
+ )
40
+ transformed_exec, flow_steps, initial_flow_steps = transform(
41
+ exec_by_flow, common_job_parameters["REALM"]
42
+ )
43
+ load_authenticationexecutions(
44
+ neo4j_session,
45
+ transformed_exec,
46
+ common_job_parameters["REALM"],
47
+ common_job_parameters["UPDATE_TAG"],
48
+ )
49
+ load_execution_flow(
50
+ neo4j_session,
51
+ flow_steps,
52
+ initial_flow_steps,
53
+ common_job_parameters["REALM_ID"],
54
+ common_job_parameters["UPDATE_TAG"],
55
+ )
56
+ cleanup(neo4j_session, common_job_parameters)
57
+
58
+
59
+ @timeit
60
+ def get(
61
+ api_session: requests.Session, base_url: str, realm: str, flow_aliases: list[str]
62
+ ) -> dict[str, list[dict[str, Any]]]:
63
+ """Fetch authentication execution data for each flow from Keycloak API.
64
+
65
+ Args:
66
+ api_session: Authenticated requests session
67
+ base_url: Keycloak base URL
68
+ realm: Target realm name
69
+ flow_aliases: List of authentication flow names to process
70
+
71
+ Returns:
72
+ Dictionary mapping flow names to their execution lists
73
+ """
74
+ results: dict[str, list[dict[str, Any]]] = {}
75
+ for flow_name in flow_aliases:
76
+ # URL-encode flow names to handle special characters safely
77
+ encoded_flow_name = quote(flow_name, safe="")
78
+ req = api_session.get(
79
+ f"{base_url}/admin/realms/{realm}/authentication/flows/{encoded_flow_name}/executions",
80
+ timeout=_TIMEOUT,
81
+ )
82
+ req.raise_for_status()
83
+ results[flow_name] = req.json()
84
+ return results
85
+
86
+
87
+ def _recursive_transform_flow(
88
+ root_executions: list[dict[str, Any]],
89
+ ) -> tuple[list[str], list[tuple[str, str]], list[str]]:
90
+ """Recursively transforms Keycloak authentication executions into a flow graph structure.
91
+
92
+ This function processes authentication executions and builds a directed graph representation
93
+ suitable for Neo4j ingestion. It handles different execution requirements (REQUIRED,
94
+ ALTERNATIVE, CONDITIONAL, DISABLED) and nested subflows.
95
+
96
+ The function returns three components:
97
+ - entries: Execution IDs that serve as entry points to the flow
98
+ - links: Tuples representing directed edges between executions
99
+ - outs: Execution IDs that serve as exit points from the flow
100
+
101
+ Each execution dict must contain:
102
+ - id: Unique execution identifier
103
+ - requirement: Execution requirement type (REQUIRED/ALTERNATIVE/CONDITIONAL/DISABLED)
104
+ - _children: List of nested child executions (for subflows)
105
+
106
+ Args:
107
+ root_executions: List of execution dictionaries to process
108
+
109
+ Returns:
110
+ A tuple containing (entry_points, execution_links, exit_points)
111
+ """
112
+ entries: list[str] = []
113
+ links: list[tuple[str, str]] = []
114
+ outs: list[str] = []
115
+
116
+ for execution in root_executions:
117
+ # Skip disabled executions as they don't participate in the flow
118
+ if execution["requirement"] == "DISABLED":
119
+ continue
120
+
121
+ if execution["requirement"] == "REQUIRED":
122
+ # If no entry point exists, this required execution becomes the flow's starting point
123
+ if len(entries) == 0:
124
+ entries.append(execution["id"])
125
+
126
+ # Connect all current outputs to this required execution
127
+ for i in outs:
128
+ links.append((i, execution["id"]))
129
+
130
+ # Handle subflow execution: recursively process children and wire them up
131
+ if len(execution.get("_children", [])) > 0:
132
+ c_ins, c_links, c_outs = _recursive_transform_flow(
133
+ execution["_children"]
134
+ )
135
+ for c_in in c_ins:
136
+ links.append((execution["id"], c_in))
137
+ outs = c_outs
138
+ links.extend(c_links)
139
+ # For leaf executions, this becomes the sole output
140
+ else:
141
+ outs = [execution["id"]] # Reset outs to the current execution
142
+
143
+ continue
144
+
145
+ if execution["requirement"] == "ALTERNATIVE":
146
+ # Alternative executions create branching paths (OR logic)
147
+ # This execution becomes an alternative entry point while preserving existing outputs
148
+ entries.append(execution["id"])
149
+
150
+ # Process subflow: wire up child inputs and aggregate child outputs
151
+ if len(execution.get("_children", [])) > 0:
152
+ c_ins, c_links, c_outs = _recursive_transform_flow(
153
+ execution["_children"]
154
+ )
155
+ for c_in in c_ins:
156
+ links.append((execution["id"], c_in))
157
+ for c_out in c_outs:
158
+ outs.append(c_out)
159
+ links.extend(c_links)
160
+ else:
161
+ outs.append(execution["id"])
162
+
163
+ continue
164
+
165
+ if execution["requirement"] == "CONDITIONAL":
166
+ # Conditional executions only apply to subflows - skip if no children
167
+ if len(execution.get("_children", [])) == 0:
168
+ continue
169
+
170
+ # Conditional logic creates two possible paths:
171
+ # 1. Subflow evaluates to True: execution is treated as required
172
+ # 2. Subflow evaluates to False: execution is skipped
173
+
174
+ # Make this execution an entry point if none exist
175
+ if len(entries) == 0:
176
+ entries.append(execution["id"])
177
+
178
+ # Connect all existing outputs to this conditional execution
179
+ for i in outs:
180
+ links.append((i, execution["id"]))
181
+
182
+ # Process child executions recursively
183
+ c_ins, c_links, c_outs = _recursive_transform_flow(execution["_children"])
184
+
185
+ # Wire this execution to child entry points
186
+ for c_in in c_ins:
187
+ links.append((execution["id"], c_in))
188
+
189
+ # Preserve both existing outputs and child outputs to model both conditional paths
190
+ outs.extend(c_outs)
191
+
192
+ # Add child links to the overall link collection
193
+ links.extend(c_links)
194
+
195
+ return entries, links, outs
196
+
197
+
198
+ def transform(
199
+ exec_by_flow: dict[str, list[dict[str, Any]]], realm: str
200
+ ) -> tuple[list[dict[str, Any]], list[dict[str, str]], list[dict[str, str]]]:
201
+ transformed_by_id: OrderedDict[str, dict[str, Any]] = OrderedDict()
202
+ initial_flow_steps: list[dict[str, str]] = []
203
+ flow_steps: list[dict[str, str]] = []
204
+
205
+ for flow_name, executions in exec_by_flow.items():
206
+ _parent_by_level: dict[int, str] = {}
207
+ _root_executions: list[dict[str, Any]] = []
208
+
209
+ # Transform executions to include parent flow/subflow relationships
210
+ # and create a hierarchical structure for graph processing
211
+ for execution in executions:
212
+ # Level 0 executions belong directly to the named flow
213
+ if execution["level"] == 0:
214
+ execution["_parent_flow"] = flow_name
215
+ _root_executions.append(execution)
216
+ else:
217
+ # Nested executions belong to their parent subflow
218
+ execution["_parent_subflow"] = _parent_by_level[execution["level"] - 1]
219
+ transformed_by_id[execution["_parent_subflow"]]["_children"].append(
220
+ execution
221
+ )
222
+
223
+ # Track subflow parents for the next nesting level
224
+ if execution.get("authenticationFlow", True):
225
+ _parent_by_level[execution["level"]] = execution["id"]
226
+
227
+ execution["_children"] = []
228
+ execution["is_terminal_step"] = False # Placeholder for terminal step flag
229
+ transformed_by_id[execution["id"]] = execution
230
+
231
+ # Process authentication flow structure and build execution graph
232
+ # Reference: https://www.keycloak.org/docs/latest/server_admin/index.html#_execution-requirements
233
+ entries, links, terminals = _recursive_transform_flow(_root_executions)
234
+
235
+ for entry in entries:
236
+ initial_flow_steps.append(
237
+ {
238
+ "flow_name": flow_name,
239
+ "execution_id": entry,
240
+ "realm": realm,
241
+ }
242
+ )
243
+
244
+ for link in links:
245
+ flow_steps.append(
246
+ {
247
+ "source": link[0],
248
+ "target": link[1],
249
+ }
250
+ )
251
+
252
+ for node_id in terminals:
253
+ transformed_by_id[node_id]["is_terminal_step"] = True
254
+
255
+ return list(transformed_by_id.values()), flow_steps, initial_flow_steps
256
+
257
+
258
+ @timeit
259
+ def load_authenticationexecutions(
260
+ neo4j_session: neo4j.Session,
261
+ data: list[dict[str, Any]],
262
+ realm: str,
263
+ update_tag: int,
264
+ ) -> None:
265
+ logger.info(
266
+ "Loading %d Keycloak AuthenticationExecutions (%s) into Neo4j.",
267
+ len(data),
268
+ realm,
269
+ )
270
+ load(
271
+ neo4j_session,
272
+ KeycloakAuthenticationExecutionSchema(),
273
+ data,
274
+ LASTUPDATED=update_tag,
275
+ REALM=realm,
276
+ )
277
+
278
+
279
+ def load_execution_flow(
280
+ neo4j_session: neo4j.Session,
281
+ flow_steps: list[dict[str, Any]],
282
+ initial_flow_steps: list[dict[str, str]],
283
+ realm_id: str,
284
+ update_tag: int,
285
+ ) -> None:
286
+ load_matchlinks(
287
+ neo4j_session,
288
+ ExecutionToExecutionMatchLink(),
289
+ flow_steps,
290
+ LASTUPDATED=update_tag,
291
+ _sub_resource_label="KeycloakRealm",
292
+ _sub_resource_id=realm_id,
293
+ )
294
+ load_matchlinks(
295
+ neo4j_session,
296
+ ExecutionToFlowMatchLink(),
297
+ initial_flow_steps,
298
+ LASTUPDATED=update_tag,
299
+ _sub_resource_label="KeycloakRealm",
300
+ _sub_resource_id=realm_id,
301
+ )
302
+
303
+
304
+ @timeit
305
+ def cleanup(
306
+ neo4j_session: neo4j.Session, common_job_parameters: dict[str, Any]
307
+ ) -> None:
308
+ GraphJob.from_node_schema(
309
+ KeycloakAuthenticationExecutionSchema(), common_job_parameters
310
+ ).run(neo4j_session)
311
+ GraphJob.from_matchlink(
312
+ ExecutionToExecutionMatchLink(),
313
+ "KeycloakRealm",
314
+ common_job_parameters["REALM_ID"],
315
+ common_job_parameters["UPDATE_TAG"],
316
+ ).run(neo4j_session)
317
+ GraphJob.from_matchlink(
318
+ ExecutionToFlowMatchLink(),
319
+ "KeycloakRealm",
320
+ common_job_parameters["REALM_ID"],
321
+ common_job_parameters["UPDATE_TAG"],
322
+ ).run(neo4j_session)
@@ -0,0 +1,77 @@
1
+ import logging
2
+ from typing import Any
3
+
4
+ import neo4j
5
+ import requests
6
+
7
+ from cartography.client.core.tx import load
8
+ from cartography.graph.job import GraphJob
9
+ from cartography.intel.keycloak.util import get_paginated
10
+ from cartography.models.keycloak.authenticationflow import (
11
+ KeycloakAuthenticationFlowSchema,
12
+ )
13
+ from cartography.util import timeit
14
+
15
+ logger = logging.getLogger(__name__)
16
+ # Connect and read timeouts of 60 seconds each; see https://requests.readthedocs.io/en/master/user/advanced/#timeouts
17
+ _TIMEOUT = (60, 60)
18
+
19
+
20
+ @timeit
21
+ def sync(
22
+ neo4j_session: neo4j.Session,
23
+ api_session: requests.Session,
24
+ base_url: str,
25
+ common_job_parameters: dict[str, Any],
26
+ ) -> list[dict[str, Any]]:
27
+ authenticationflows = get(
28
+ api_session,
29
+ base_url,
30
+ common_job_parameters["REALM"],
31
+ )
32
+ load_authenticationflows(
33
+ neo4j_session,
34
+ authenticationflows,
35
+ common_job_parameters["REALM"],
36
+ common_job_parameters["UPDATE_TAG"],
37
+ )
38
+ cleanup(neo4j_session, common_job_parameters)
39
+ return authenticationflows
40
+
41
+
42
+ @timeit
43
+ def get(
44
+ api_session: requests.Session,
45
+ base_url: str,
46
+ realm: str,
47
+ ) -> list[dict[str, Any]]:
48
+ url = f"{base_url}/admin/realms/{realm}/authentication/flows"
49
+ return list(get_paginated(api_session, url, params={"briefRepresentation": False}))
50
+
51
+
52
+ @timeit
53
+ def load_authenticationflows(
54
+ neo4j_session: neo4j.Session,
55
+ data: list[dict[str, Any]],
56
+ realm: str,
57
+ update_tag: int,
58
+ ) -> None:
59
+ logger.info(
60
+ "Loading %d Keycloak AuthenticationFlows (%s) into Neo4j.", len(data), realm
61
+ )
62
+ load(
63
+ neo4j_session,
64
+ KeycloakAuthenticationFlowSchema(),
65
+ data,
66
+ LASTUPDATED=update_tag,
67
+ REALM=realm,
68
+ )
69
+
70
+
71
+ @timeit
72
+ def cleanup(
73
+ neo4j_session: neo4j.Session, common_job_parameters: dict[str, Any]
74
+ ) -> None:
75
+ GraphJob.from_node_schema(
76
+ KeycloakAuthenticationFlowSchema(), common_job_parameters
77
+ ).run(neo4j_session)
@@ -0,0 +1,187 @@
1
+ import logging
2
+ from typing import Any
3
+
4
+ import neo4j
5
+ import requests
6
+
7
+ from cartography.client.core.tx import load
8
+ from cartography.client.core.tx import load_matchlinks
9
+ from cartography.graph.job import GraphJob
10
+ from cartography.intel.keycloak.util import get_paginated
11
+ from cartography.models.keycloak.client import KeycloakClientSchema
12
+ from cartography.models.keycloak.client import KeycloakClientToFlowMatchLink
13
+ from cartography.models.keycloak.user import KeycloakUserSchema
14
+ from cartography.util import timeit
15
+
16
+ logger = logging.getLogger(__name__)
17
+ # Connect and read timeouts of 60 seconds each; see https://requests.readthedocs.io/en/master/user/advanced/#timeouts
18
+ _TIMEOUT = (60, 60)
19
+
20
+
21
+ @timeit
22
+ def sync(
23
+ neo4j_session: neo4j.Session,
24
+ api_session: requests.Session,
25
+ base_url: str,
26
+ common_job_parameters: dict[str, Any],
27
+ realm_default_flows: dict[str, Any],
28
+ ) -> list[dict]:
29
+ clients = get(
30
+ api_session,
31
+ base_url,
32
+ common_job_parameters["REALM"],
33
+ )
34
+ transformed_clients, service_accounts, flows_binding = transform(
35
+ clients, realm_default_flows
36
+ )
37
+ load_service_accounts(
38
+ neo4j_session,
39
+ service_accounts,
40
+ common_job_parameters["REALM"],
41
+ common_job_parameters["UPDATE_TAG"],
42
+ )
43
+ load_clients(
44
+ neo4j_session,
45
+ transformed_clients,
46
+ common_job_parameters["REALM"],
47
+ common_job_parameters["UPDATE_TAG"],
48
+ )
49
+ load_flow_bindings(
50
+ neo4j_session,
51
+ flows_binding,
52
+ common_job_parameters["REALM_ID"],
53
+ common_job_parameters["UPDATE_TAG"],
54
+ )
55
+ cleanup(neo4j_session, common_job_parameters)
56
+ return clients
57
+
58
+
59
+ @timeit
60
+ def get(
61
+ api_session: requests.Session,
62
+ base_url: str,
63
+ realm: str,
64
+ ) -> list[dict[str, Any]]:
65
+ result: list[dict[str, Any]] = []
66
+ url = f"{base_url}/admin/realms/{realm}/clients"
67
+ for client in get_paginated(
68
+ api_session, url, params={"briefRepresentation": False}
69
+ ):
70
+ # Check if the client has a service account user
71
+ if "service_account" in client.get("defaultClientScopes", []):
72
+ # Get service account user for each client
73
+ service_account_url = f"{base_url}/admin/realms/{realm}/clients/{client['id']}/service-account-user"
74
+ sa_req = api_session.get(
75
+ service_account_url,
76
+ timeout=_TIMEOUT,
77
+ params={"briefRepresentation": False},
78
+ )
79
+ sa_req.raise_for_status()
80
+ client["service_account_user"] = sa_req.json()
81
+ result.append(client)
82
+ return result
83
+
84
+
85
+ def transform(
86
+ clients: list[dict[str, Any]], default_flows: dict[str, Any]
87
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]:
88
+ transformed_clients = []
89
+ service_accounts = []
90
+ flow_bindings = []
91
+ for client in clients:
92
+ sa = client.get("service_account_user")
93
+ if sa:
94
+ service_accounts.append(sa)
95
+ client["_service_account_user_id"] = sa["id"]
96
+ client.pop("service_account_user", None)
97
+
98
+ for flow_name, default_flow_id in default_flows.items():
99
+ flow_binding = {
100
+ "client_id": client["id"],
101
+ "flow_name": flow_name,
102
+ "flow_id": default_flow_id,
103
+ "default_flow": True,
104
+ }
105
+ if client.get("authenticationFlowBindingOverrides", {}).get(flow_name):
106
+ flow_binding["flow_id"] = client["authenticationFlowBindingOverrides"][
107
+ flow_name
108
+ ]
109
+ flow_binding["default_flow"] = False
110
+ flow_bindings.append(flow_binding)
111
+
112
+ transformed_clients.append(client)
113
+ return transformed_clients, service_accounts, flow_bindings
114
+
115
+
116
+ @timeit
117
+ def load_clients(
118
+ neo4j_session: neo4j.Session,
119
+ data: list[dict[str, Any]],
120
+ realm: str,
121
+ update_tag: int,
122
+ ) -> None:
123
+ logger.info("Loading %d Keycloak Clients (%s) into Neo4j.", len(data), realm)
124
+ load(
125
+ neo4j_session,
126
+ KeycloakClientSchema(),
127
+ data,
128
+ LASTUPDATED=update_tag,
129
+ REALM=realm,
130
+ )
131
+
132
+
133
+ @timeit
134
+ def load_service_accounts(
135
+ neo4j_session: neo4j.Session,
136
+ data: list[dict[str, Any]],
137
+ realm: str,
138
+ update_tag: int,
139
+ ) -> None:
140
+ logger.info(
141
+ "Loading %d Keycloak Service Accounts (%s) into Neo4j.", len(data), realm
142
+ )
143
+ load(
144
+ neo4j_session,
145
+ KeycloakUserSchema(),
146
+ data,
147
+ LASTUPDATED=update_tag,
148
+ REALM=realm,
149
+ )
150
+
151
+
152
+ @timeit
153
+ def load_flow_bindings(
154
+ neo4j_session: neo4j.Session,
155
+ biddings: list[dict[str, Any]],
156
+ realm_id: str,
157
+ update_tag: int,
158
+ ) -> None:
159
+ load_matchlinks(
160
+ neo4j_session,
161
+ KeycloakClientToFlowMatchLink(),
162
+ biddings,
163
+ LASTUPDATED=update_tag,
164
+ _sub_resource_label="KeycloakRealm",
165
+ _sub_resource_id=realm_id,
166
+ )
167
+
168
+
169
+ @timeit
170
+ def cleanup(
171
+ neo4j_session: neo4j.Session, common_job_parameters: dict[str, Any]
172
+ ) -> None:
173
+ GraphJob.from_node_schema(KeycloakClientSchema(), common_job_parameters).run(
174
+ neo4j_session
175
+ )
176
+ # It's OK to cleanup users here as it will not clean the regular users (which have the same UPDATE_TAG)
177
+ GraphJob.from_node_schema(KeycloakUserSchema(), common_job_parameters).run(
178
+ neo4j_session
179
+ )
180
+ GraphJob.from_matchlink(
181
+ KeycloakClientToFlowMatchLink(),
182
+ sub_resource_label="KeycloakRealm",
183
+ sub_resource_id=common_job_parameters["REALM_ID"],
184
+ update_tag=common_job_parameters["UPDATE_TAG"],
185
+ ).run(
186
+ neo4j_session,
187
+ )
@@ -0,0 +1,126 @@
1
+ import logging
2
+ from typing import Any
3
+
4
+ import neo4j
5
+ import requests
6
+
7
+ from cartography.client.core.tx import load
8
+ from cartography.graph.job import GraphJob
9
+ from cartography.intel.keycloak.util import get_paginated
10
+ from cartography.models.keycloak.group import KeycloakGroupSchema
11
+ from cartography.util import timeit
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ @timeit
17
+ def sync(
18
+ neo4j_session: neo4j.Session,
19
+ api_session: requests.Session,
20
+ base_url: str,
21
+ common_job_parameters: dict[str, Any],
22
+ ) -> None:
23
+ groups = get(
24
+ api_session,
25
+ base_url,
26
+ common_job_parameters["REALM"],
27
+ )
28
+ transformed_groups = transform(groups)
29
+ load_groups(
30
+ neo4j_session,
31
+ transformed_groups,
32
+ common_job_parameters["REALM"],
33
+ common_job_parameters["UPDATE_TAG"],
34
+ )
35
+ cleanup(neo4j_session, common_job_parameters)
36
+
37
+
38
+ @timeit
39
+ def _get_subgroups(
40
+ api_session: requests.Session,
41
+ base_url: str,
42
+ realm: str,
43
+ group_id: str,
44
+ ) -> list[dict[str, Any]]:
45
+ result: list[dict[str, Any]] = []
46
+ url = f"{base_url}/admin/realms/{realm}/groups/{group_id}/children"
47
+ for group in get_paginated(
48
+ api_session,
49
+ url,
50
+ ):
51
+ group["_members"] = _get_members(api_session, base_url, realm, group["id"])
52
+ result.append(group)
53
+ if group.get("subGroupCount", 0) > 0:
54
+ result.extend(_get_subgroups(api_session, base_url, realm, group["id"]))
55
+ return result
56
+
57
+
58
+ @timeit
59
+ def _get_members(
60
+ api_session: requests.Session,
61
+ base_url: str,
62
+ realm: str,
63
+ group_id: str,
64
+ ) -> list[dict[str, Any]]:
65
+ url = f"{base_url}/admin/realms/{realm}/groups/{group_id}/members"
66
+ return list(get_paginated(api_session, url))
67
+
68
+
69
+ @timeit
70
+ def get(
71
+ api_session: requests.Session,
72
+ base_url: str,
73
+ realm: str,
74
+ ) -> list[dict[str, Any]]:
75
+ result: list[dict[str, Any]] = []
76
+
77
+ url = f"{base_url}/admin/realms/{realm}/groups"
78
+ for group in get_paginated(api_session, url, params={"briefRepresentation": False}):
79
+ group["_members"] = _get_members(api_session, base_url, realm, group["id"])
80
+ result.append(group)
81
+ if group.get("subGroupCount", 0) > 0:
82
+ result.extend(_get_subgroups(api_session, base_url, realm, group["id"]))
83
+ return result
84
+
85
+
86
+ def transform(groups: list[dict[str, Any]]) -> list[dict[str, Any]]:
87
+ for group in groups:
88
+ # Transform members to a list of IDs for easier relationship handling
89
+ group["_member_ids"] = [m["id"] for m in group["_members"]]
90
+ group.pop("_members")
91
+ # Transform roles to a list of role names for easier relationship handling
92
+ group["_roles"] = []
93
+ for role_name in group.get("realmRoles", []):
94
+ group["_roles"].append(role_name)
95
+ group.pop("realmRoles", None)
96
+ for roles in group.get("clientRoles", {}).values():
97
+ for role_name in roles:
98
+ group["_roles"].append(role_name)
99
+ group.pop("clientRoles", None)
100
+ return groups
101
+
102
+
103
+ @timeit
104
+ def load_groups(
105
+ neo4j_session: neo4j.Session,
106
+ data: list[dict[str, Any]],
107
+ realm: str,
108
+ update_tag: int,
109
+ ) -> None:
110
+ logger.info("Loading %d Keycloak Groups (%s) into Neo4j.", len(data), realm)
111
+ load(
112
+ neo4j_session,
113
+ KeycloakGroupSchema(),
114
+ data,
115
+ LASTUPDATED=update_tag,
116
+ REALM=realm,
117
+ )
118
+
119
+
120
+ @timeit
121
+ def cleanup(
122
+ neo4j_session: neo4j.Session, common_job_parameters: dict[str, Any]
123
+ ) -> None:
124
+ GraphJob.from_node_schema(KeycloakGroupSchema(), common_job_parameters).run(
125
+ neo4j_session
126
+ )