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.
- cartography/_version.py +2 -2
- cartography/cli.py +57 -0
- cartography/config.py +24 -0
- cartography/data/indexes.cypher +0 -2
- cartography/data/jobs/analysis/keycloak_inheritance.json +30 -0
- cartography/intel/aws/apigateway.py +128 -17
- cartography/intel/aws/ec2/instances.py +3 -1
- cartography/intel/aws/ec2/network_interfaces.py +1 -1
- cartography/intel/aws/ec2/vpc_peerings.py +262 -125
- cartography/intel/azure/__init__.py +35 -32
- cartography/intel/azure/subscription.py +2 -2
- cartography/intel/azure/tenant.py +39 -30
- cartography/intel/azure/util/credentials.py +49 -174
- cartography/intel/entra/__init__.py +47 -1
- cartography/intel/entra/applications.py +220 -170
- cartography/intel/entra/groups.py +41 -22
- cartography/intel/entra/ou.py +28 -20
- cartography/intel/entra/users.py +24 -18
- cartography/intel/gcp/__init__.py +25 -8
- cartography/intel/gcp/compute.py +47 -12
- cartography/intel/github/repos.py +19 -10
- cartography/intel/github/util.py +12 -0
- cartography/intel/keycloak/__init__.py +153 -0
- cartography/intel/keycloak/authenticationexecutions.py +322 -0
- cartography/intel/keycloak/authenticationflows.py +77 -0
- cartography/intel/keycloak/clients.py +187 -0
- cartography/intel/keycloak/groups.py +126 -0
- cartography/intel/keycloak/identityproviders.py +94 -0
- cartography/intel/keycloak/organizations.py +163 -0
- cartography/intel/keycloak/realms.py +61 -0
- cartography/intel/keycloak/roles.py +202 -0
- cartography/intel/keycloak/scopes.py +73 -0
- cartography/intel/keycloak/users.py +70 -0
- cartography/intel/keycloak/util.py +47 -0
- cartography/intel/kubernetes/__init__.py +26 -0
- cartography/intel/kubernetes/eks.py +402 -0
- cartography/intel/kubernetes/rbac.py +133 -0
- cartography/models/aws/apigateway/apigatewayintegration.py +79 -0
- cartography/models/aws/apigateway/apigatewaymethod.py +74 -0
- cartography/models/aws/ec2/vpc_peering.py +157 -0
- cartography/models/azure/principal.py +44 -0
- cartography/models/azure/tenant.py +20 -0
- cartography/models/keycloak/__init__.py +0 -0
- cartography/models/keycloak/authenticationexecution.py +160 -0
- cartography/models/keycloak/authenticationflow.py +54 -0
- cartography/models/keycloak/client.py +177 -0
- cartography/models/keycloak/group.py +101 -0
- cartography/models/keycloak/identityprovider.py +89 -0
- cartography/models/keycloak/organization.py +116 -0
- cartography/models/keycloak/organizationdomain.py +73 -0
- cartography/models/keycloak/realm.py +173 -0
- cartography/models/keycloak/role.py +126 -0
- cartography/models/keycloak/scope.py +73 -0
- cartography/models/keycloak/user.py +51 -0
- cartography/models/kubernetes/clusterrolebindings.py +40 -0
- cartography/models/kubernetes/groups.py +107 -0
- cartography/models/kubernetes/oidc.py +51 -0
- cartography/models/kubernetes/rolebindings.py +40 -0
- cartography/models/kubernetes/users.py +105 -0
- cartography/sync.py +2 -0
- cartography/util.py +10 -0
- {cartography-0.111.0rc1.dist-info → cartography-0.112.0.dist-info}/METADATA +9 -5
- {cartography-0.111.0rc1.dist-info → cartography-0.112.0.dist-info}/RECORD +67 -34
- cartography/data/jobs/cleanup/aws_import_vpc_peering_cleanup.json +0 -45
- {cartography-0.111.0rc1.dist-info → cartography-0.112.0.dist-info}/WHEEL +0 -0
- {cartography-0.111.0rc1.dist-info → cartography-0.112.0.dist-info}/entry_points.txt +0 -0
- {cartography-0.111.0rc1.dist-info → cartography-0.112.0.dist-info}/licenses/LICENSE +0 -0
- {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
|
+
)
|