From a4af86c375e98cd477b830fa5b07dc19dc312ed2 Mon Sep 17 00:00:00 2001 From: John Walstra Date: Thu, 17 Sep 2026 12:56:11 -0500 Subject: [PATCH] DR-1381 Add a toggle to prevent keeper-dag from deactivating similar edges if different paths. --- keepercommander/commands/pam_debug/gateway.py | 2 +- keepercommander/commands/pam_debug/graph.py | 340 +++++++++++--- keepercommander/commands/pam_debug/info.py | 116 +---- .../discovery_common/__version__.py | 2 +- .../discovery_common/record_link.py | 3 +- keepercommander/discovery_common/rm_types.py | 2 + keepercommander/keeper_dag/__version__.py | 2 +- .../keeper_dag/connection/__init__.py | 21 +- keepercommander/keeper_dag/connection/ksm.py | 439 +++++++++++++++++- keepercommander/keeper_dag/dag.py | 33 +- keepercommander/keeper_dag/edge.py | 7 +- keepercommander/keeper_dag/types.py | 355 +++++++++++++- keepercommander/keeper_dag/vertex.py | 397 ++++++++++++---- 13 files changed, 1433 insertions(+), 286 deletions(-) diff --git a/keepercommander/commands/pam_debug/gateway.py b/keepercommander/commands/pam_debug/gateway.py index bd6999000..2991d8ee7 100644 --- a/keepercommander/commands/pam_debug/gateway.py +++ b/keepercommander/commands/pam_debug/gateway.py @@ -85,7 +85,7 @@ def execute(self, params: KeeperParams, **kwargs): if record_link.dag.has_graph is True: print(self._h("Record Linking Graph")) graph.do_list(params=params, gateway_context=gateway_context, graph_type="rl", debug_level=debug_level, - indent=1) + indent=1, show_data=False) print("") print(self._h("User to Service/Task Graph")) diff --git a/keepercommander/commands/pam_debug/graph.py b/keepercommander/commands/pam_debug/graph.py index 9b6e56f33..2a08d8c92 100644 --- a/keepercommander/commands/pam_debug/graph.py +++ b/keepercommander/commands/pam_debug/graph.py @@ -1,4 +1,7 @@ from __future__ import annotations +import base64 +import binascii +import json from . import get_connection import argparse import logging @@ -11,13 +14,14 @@ from ...discovery_common.constants import (PAM_USER, PAM_DIRECTORY, PAM_MACHINE, PAM_DATABASE, VERTICES_SORT_MAP, DIS_INFRA_GRAPH_ID, RECORD_LINK_GRAPH_ID, DIS_JOBS_GRAPH_ID) from ...discovery_common.types import (DiscoveryObject, DiscoveryUser, DiscoveryDirectory, DiscoveryMachine, - DiscoveryDatabase, JobContent, ServiceAcl) + DiscoveryDatabase, JobContent, ServiceAcl, UserAcl, UserAclServiceNames) from ...discovery_common.dag_sort import sort_infra_vertices from ...keeper_dag import DAG from ...keeper_dag.types import GRAPH_ID_TO_ENDPOINT, PamEndpoints from ...keeper_dag.vertex import DAGVertex -from ...keeper_dag.edge import EdgeType -from typing import TYPE_CHECKING +from ...keeper_dag.edge import EdgeType, DAGEdge +from ...crypto import decrypt_aes_v2 +from typing import TYPE_CHECKING, Optional, List if TYPE_CHECKING: from ...params import KeeperParams @@ -53,6 +57,8 @@ class PAMDebugGraphCommand(PAMGatewayActionDiscoverCommandBase): dest='format', default="dot", action='store', help='The format of the graph.') parser.add_argument('--debug-gs-level', required=False, dest='debug_level', action='store', help='GraphSync debug level. Default is 0', type=int, default=0) + parser.add_argument('--history-level', required=False, dest='history_level', action='store', + help='Levels of history to keep. Default is 0', type=int, default=0) mapping = { PAM_USER: {"order": 1, "sort": "_sort_name", "item": DiscoveryUser, "key": "user"}, @@ -70,8 +76,25 @@ class PAMDebugGraphCommand(PAMGatewayActionDiscoverCommandBase): def get_parser(self): return PAMDebugGraphCommand.parser + @staticmethod + def is_base64(value) -> bool: + # Ensure the input is treated as bytes (or an ASCII string) + if isinstance(value, str): + value_bytes = value.encode('utf-8') + elif isinstance(value, (bytes, bytearray)): + value_bytes = value + else: + return False + + try: + # validate=True ensures characters outside the base64 alphabet throw an error + base64.b64decode(value_bytes, validate=True) + return True + except binascii.Error: + return False + def _do_text_list_infra(self, params: KeeperParams, gateway_context: GatewayContext, debug_level: int = 0, - indent: int = 0): + indent: int = 0, **kwargs): infra = Infrastructure(record=gateway_context.configuration, params=params, logger=logging, debug_level=debug_level, use_per_graph_endpoints=False) @@ -148,8 +171,80 @@ def _handle(current_vertex: DAGVertex, indent: int = 0, last_record_type: str | _handle(configuration, indent=indent) print("") + def _do_acl(self, + acl_edge:DAGEdge, + record_key: bytes, + params: KeeperParams, + pad: Optional[str] = ""): + + acl = acl_edge.content_as_object(UserAcl) + if acl is None: + print(f"{pad} {self._f('missing ACL')}") + else: + print(f"{pad} . acl: {acl_edge.content}") + print(f"{pad} . path {acl_edge.path}") + if acl.is_iam_user: + print(f"{pad} . is IAM user") + if acl.is_admin: + print(f"{pad} . is the {self._b('Admin')}") + if acl.belongs_to: + print(f"{pad} . local user") + else: + print(f"{pad} . looks like directory user") + + if acl.controls_services: + print(f"{pad} . controls services") + + if acl.service_names: + + for service_names in acl.get_service_names(record_key): # type: UserAclServiceNames + print(f"{pad} - {service_names.type.value}") + for item in service_names.items: + print(f"{pad} > {item.name} " + f"{'... via discovery' if item.via_discovery else '... manual'}") + else: + print(f"{pad} . service names not set") + else: + print(f"{pad} . does not control services") + + if acl.rotation_settings is not None: + if acl.rotation_settings.schedule: + print(f"{pad} . schedule: {acl.rotation_settings.get_schedule()}") + else: + print(f"{pad} . schedule: None") + if acl.rotation_settings.pwd_complexity: + print(f"{pad} . password complexity: {acl.rotation_settings.get_pwd_complexity(record_key)}") + else: + print(f"{pad} . password complexity: None") + if acl.rotation_settings.noop: + print(f"{pad} . is a NOOP") + if acl.rotation_settings.disabled: + print(f"{pad} . rotation is disabled") + + if (acl.rotation_settings.saas_record_uid_list is not None + and len(acl.rotation_settings.saas_record_uid_list) > 0): + print(f"{pad} . has SaaS rotation: " + f"{acl.rotation_settings.saas_record_uid_list[0]}") + + if len(acl.rotation_settings.saas_record_uid_list) > 0: + if acl.rotation_settings.noop: + saas_config_uid = acl.rotation_settings.saas_record_uid_list[0] + saas_config = load_pam_record( + params, + saas_config_uid) # type: TypedRecord | None + + print(f" . SaaS configuration record is {saas_config.title}") + else: + print(f"{bcolors.FAIL} . Has SaaS plugin config record, " + f"however it's not NOOP{bcolors.ENDC}") + else: + print(f"{pad}{bcolors.FAIL} . there are no rotation settings in ACL!{bcolors.ENDC}") + def _do_text_list_rl(self, params: KeeperParams, gateway_context: GatewayContext, debug_level: int = 0, - indent: int = 0): + indent: int = 0, + record_uids: Optional[List[str]] = None, + show_data: bool = True, + **kwargs): print("") @@ -163,12 +258,12 @@ def _do_text_list_rl(self, params: KeeperParams, gateway_context: GatewayContext debug_level=debug_level, use_per_graph_endpoints=False) configuration = record_link.dag.get_root - record = load_pam_record(params, configuration.uid) # type: TypedRecord | None - if record is None: + config_record = load_pam_record(params, configuration.uid) # type: TypedRecord | None + if config_record is None: print(self._f("Configuration record does not exists.")) return - print(self._h(f"{pad}{record.record_type}, {record.title}, {record.record_uid}")) + print(self._h(f"{pad}{config_record.record_type}, {config_record.title}, {config_record.record_uid}")) if configuration.has_data: try: @@ -191,6 +286,24 @@ def _group(configuration_vertex: DAGVertex) -> dict: } for vertex in configuration_vertex.has_vertices(): + + users_uids: Optional[List] = None + if record_uids is not None: + found = vertex.uid in record_uids + if not found: + # Get the resource's users + children_uids = [x.uid for x in vertex.has_vertices() if x is not None] + for record_uid in record_uids: + if record_uid in children_uids: + found = True + if users_uids is None: + users_uids = [] + users_uids.append(record_uid) + break + + if not found: + continue + record = load_pam_record(params, vertex.uid) # type: TypedRecord | None if record is None: group[PAMDebugGraphCommand.NO_RECORD].append({ @@ -202,95 +315,142 @@ def _group(configuration_vertex: DAGVertex) -> dict: rt = PAMDebugGraphCommand.OTHER group[rt].append({ "v": vertex, - "r": record + "r": record, + "u": users_uids }) return group group = _group(configuration) - + for record_type in [PAM_USER, PAM_DIRECTORY, PAM_MACHINE, PAM_DATABASE]: if len(group[record_type]) > 0: print(f"{pad} " + self._b(self._n(record_type))) for item in group[record_type]: vertex = item.get("v") # type: DAGVertex record = item.get("r") # type: TypedRecord + user_uids = item.get("u") # type: Optional[List[str]] text = self._gr(f"{record.title}; {record.record_uid} ") if not vertex.active: text += " " + self._f("Inactive") print(f"{pad} * {text}") - # These are cloud users - if record_type == PAM_USER: - acl = record_link.get_acl(vertex.uid, configuration.uid) - if acl is None: - print(f"{pad} {self._f('missing ACL')}") - else: - if acl.is_iam_user: - print(f"{pad} . is IAM user") - if acl.is_admin: - print(f"{pad} . is the {self._b('Admin')}") - if acl.belongs_to: - print(f"{pad} . belongs to this resource") - else: - print(f"{pad} . looks like directory user") + # Resource connection to configuration. - if acl.rotation_settings: - if acl.rotation_settings.noop: - print(f"{pad} . is a NOOP") - if acl.rotation_settings.disabled: - print(f"{pad} . rotation is disabled") + for edge in vertex.edges: + if not edge: + continue - if (acl.rotation_settings.saas_record_uid_list is not None - and len(acl.rotation_settings.saas_record_uid_list) > 0): - print(f"{pad} . has SaaS rotation: " - f"{acl.rotation_settings.saas_record_uid_list[0]}") + print(f"{pad} {bcolors.WARNING}{edge.edge_type.value.upper()}{bcolors.ENDC}") + print(f"{pad} . path = {edge.path}") + print(f"{pad} . active = {edge.active}") - continue + if edge.edge_type == EdgeType.ACL: + self._do_acl(acl_edge=edge, + params=params, + pad=pad, + record_key=record.record_key) + else: + if show_data and edge.content and edge.content is not None: + try: + data = edge.content_as_dict + if data is not None: + print(f"{pad} . data") + for k, v in data.items(): + print(f"{pad} + {k} = {v}") + else: + print(f"{pad} . data is None") + except Exception as err: + content = edge.content + print(f"{pad} ! data not JSON: {err}") + print(f"{pad} {content}") - if vertex.has_data: - try: - data = vertex.content_as_dict - print(f"{pad} . data") - for k, v in data.items(): - print(f"{pad} + {k} = {v}") - except Exception as err: - print(f"{pad} ! data not JSON: {err}") + if self.is_base64(content): + print(f"{pad} . is base64") + content = base64.b64decode(content) + try: + content = json.loads(content) + print(f"{pad} . is JSON") + for k, v in content.items(): + print(f"{pad} + {k} = {v}") + except (Exception): + + try: + content = decrypt_aes_v2(content, record.record_key) + print(f"{pad} . encrypted with resource record bytes") + except (Exception,): + try: + content = decrypt_aes_v2(content, config_record.record_key) + print(f"{pad} . encrypted with configuration record bytes") + except (Exception,): + print(f"{pad} !! cannot decrypt") + content = None + + if content is not None: + try: + content = json.loads(content) + print(f"{pad} . is JSON") + for k, v in content.items(): + print(f"{pad} + {k} = {v}") + except (Exception): + print(f"{pad} !! decrypt data is not JSON") + + # Get the resource's users children = vertex.has_vertices() if len(children) > 0: + + print("") + print(f"{pad} {bcolors.BOLD}Users{bcolors.ENDC}") + bad = [] for child in children: + if user_uids is not None and child.uid not in user_uids: + continue + child_record = load_pam_record(params, child.uid) # type: TypedRecord | None if child_record is None: if child.active: bad.append(self._f(f"- Record UID {child.uid} does not exists.")) continue else: - print(f"{pad} - {child_record.title}; {child_record.record_uid}") - acl = record_link.get_acl(child.uid, vertex.uid) - if acl is None: - print(f"{pad} {self._f('missing ACL')}") - else: - if acl.is_admin: - print(f"{pad} . is the {self._b('Admin')}") - if acl.belongs_to: - print(f"{pad} . belongs to this resource") + print(f"{pad} * {bcolors.OKBLUE}{child_record.title}{bcolors.ENDC}; " + f"{child_record.record_uid}") + + for edge in child.edges: + if edge and edge.head_uid == child.uid: + if edge.content: + try: + data = edge.content_as_dict + if data is not None: + print(f"{pad} . data") + for k, v in data.items(): + print(f"{pad} + {k} = {v}") + else: + print(f"{pad} . data is None") + except Exception as err: + content = edge.content + print(f"{pad} ! data not JSON: {err}") + print(f"{pad} {content}") + + for edge in child.edges: + if edge is None or edge.head_uid != record.record_uid: + continue + + if edge.edge_type == EdgeType.ACL: + self._do_acl(acl_edge=edge, + params=params, + pad=pad, + record_key=child_record.record_key) else: - print(f"{pad} . looks like directory user") - - if child.has_data: - try: - data = child.content_as_dict - print(f"{pad} . data") - for k, v in data.items(): - print(f"{pad} + {k} = {v}") - except Exception as err: - print(f"{pad} ! data not JSON: {err}") + print(f"{pad} {self._f('not ACL')}") for i in bad: - print(f"{pad} " + i) + print(f"{pad} " + i) + + print("") if len(group[PAMDebugGraphCommand.OTHER]) > 0: + print("") print(f"{pad} " + self._b("Other PAM Types")) for item in group[PAMDebugGraphCommand.OTHER]: vertex = item.get("v") # type: DAGVertex @@ -301,15 +461,37 @@ def _group(configuration_vertex: DAGVertex) -> dict: print(f"{pad} * {text}") if len(group[PAMDebugGraphCommand.NO_RECORD]) > 0: - - # TODO: Check the infra graph for information - print(f"{pad} " + self._b(self._n("In Graph, No Vault Record"))) + print("") + print(f"{pad} " + self._b("No Record; In Graph; Active")) for item in group[PAMDebugGraphCommand.NO_RECORD]: vertex = item.get("v") # type: DAGVertex - print(f"{pad} * {vertex.uid}") + if not vertex.active: + continue + for edge in vertex.edges: + if edge is None or edge.content is None or not edge.active: + continue + text = vertex.uid + if edge.path is not None and edge.path != "": + text += f"; path: {edge.path}" + if not vertex.active: + text += " " + self._f("Inactive") + print(f"{pad} * {text}") + + if show_data and edge.content: + try: + data = edge.content_as_dict + if data is not None: + print(f"{pad} . data") + for k, v in data.items(): + print(f"{pad} + {k} = {v}") + else: + print(f"{pad} . data is None") + except Exception as err: + print(f"{pad} ! data not JSON") + print(f"{pad} {edge.content}") def _do_text_list_service(self, params: KeeperParams, gateway_context: GatewayContext, debug_level: int = 0, - indent: int = 0): + indent: int = 0, **kwargs): pad = "" if indent > 0: @@ -413,7 +595,7 @@ def _do_text_list_service(self, params: KeeperParams, gateway_context: GatewayCo print(f"{pad} * {user}") def _do_text_list_jobs(self, params: KeeperParams, gateway_context: GatewayContext, debug_level: int = 0, - indent: int = 0): + indent: int = 0, **kwargs): infra = Infrastructure(record=gateway_context.configuration, params=params, logger=logging, debug_level=debug_level, fail_on_corrupt=False, use_per_graph_endpoints=False) @@ -578,7 +760,7 @@ def _do_render_jobs(self, params: KeeperParams, gateway_context: GatewayContext, print("") def _do_raw_text_list(self, params: KeeperParams, gateway_context: GatewayContext, graph_id: int = 0, - debug_level: int = 0): + debug_level: int = 0, history_level: int = 0, **kwargs): logging.debug(f"loading graph id {graph_id}, for record uid {gateway_context.configuration.record_uid}") @@ -586,7 +768,8 @@ def _do_raw_text_list(self, params: KeeperParams, gateway_context: GatewayContex endpoint = GRAPH_ID_TO_ENDPOINT[graph_id] dag = DAG(conn=conn, record=gateway_context.configuration, read_endpoint=endpoint, write_endpoint=endpoint, - fail_on_corrupt=False, logger=logging, debug_level=debug_level) + fail_on_corrupt=False, logger=logging, debug_level=debug_level, + history_level=history_level) dag.load(sync_point=0) print("") if dag.is_corrupt is True: @@ -655,13 +838,14 @@ def _handle(current_vertex: DAGVertex, last_vertex: DAGVertex | None = None, ind print("") def _do_raw_render_graph(self, params: KeeperParams, gateway_context: GatewayContext, filepath: str, - graph_format: str, graph_id: int = 0, debug_level: int = 0): + graph_format: str, graph_id: int = 0, debug_level: int = 0, history_level: int = 0): conn = get_connection(params=params) endpoint = GRAPH_ID_TO_ENDPOINT[graph_id] dag = DAG(conn=conn, record=gateway_context.configuration, read_endpoint=endpoint, write_endpoint=endpoint, - fail_on_corrupt=False, logger=logging, debug_level=debug_level) + fail_on_corrupt=False, logger=logging, debug_level=debug_level, + history_level=history_level) dag.load(sync_point=0) dot = dag.to_dot(graph_format=graph_format) if graph_format == "raw": @@ -677,12 +861,13 @@ def _do_raw_render_graph(self, params: KeeperParams, gateway_context: GatewayCon print("") def do_list(self, params: KeeperParams, gateway_context: GatewayContext, graph_type: str, debug_level: int = 0, - indent: int = 0): + indent: int = 0, **kwargs): list_func = getattr(self, f"_do_text_list_{graph_type}") list_func(params=params, gateway_context=gateway_context, debug_level=debug_level, - indent=indent) + indent=indent, + **kwargs) def execute(self, params: KeeperParams, **kwargs): @@ -692,6 +877,7 @@ def execute(self, params: KeeperParams, **kwargs): do_text_list = kwargs.get("do_text_list") do_render = kwargs.get("do_render") debug_level = int(kwargs.get("debug_level", 0)) + history_level = int(kwargs.get("history_level", 0)) configuration_uid = kwargs.get('configuration_uid') try: @@ -710,7 +896,8 @@ def execute(self, params: KeeperParams, **kwargs): self._do_raw_text_list(params=params, gateway_context=gateway_context, graph_id=PAMDebugGraphCommand.graph_id_map.get(graph_type), - debug_level=debug_level) + debug_level=debug_level, + history_level=history_level) if do_render: filepath = kwargs.get("filepath") graph_format = kwargs.get("format") @@ -719,7 +906,8 @@ def execute(self, params: KeeperParams, **kwargs): filepath=filepath, graph_format=graph_format, graph_id=PAMDebugGraphCommand.graph_id_map.get(graph_type), - debug_level=debug_level) + debug_level=debug_level, + history_level=history_level) else: if do_text_list: self.do_list( diff --git a/keepercommander/commands/pam_debug/info.py b/keepercommander/commands/pam_debug/info.py index 74ad5d3db..5e2918c55 100644 --- a/keepercommander/commands/pam_debug/info.py +++ b/keepercommander/commands/pam_debug/info.py @@ -8,6 +8,7 @@ from ...discovery_common.types import UserAcl, DiscoveryObject, ServiceEnum from ...discovery_common.constants import PAM_USER, PAM_MACHINE, PAM_DATABASE, PAM_DIRECTORY from ...keeper_dag import EdgeType +from .graph import PAMDebugGraphCommand import time import re import json @@ -170,117 +171,10 @@ def _print_field(f): record_vertex = record_link.dag.get_vertex(record.record_uid) if record_vertex is not None: - print(self._h("Record Linking")) - - print(self._b(" Record Data (meta)")) - print(f" Raw JSON: {json.dumps(record_vertex.content_as_dict)}") - print("") - - record_parent_vertices = record_vertex.belongs_to_vertices() - print(self._b(" Parent Records")) - if len(record_parent_vertices) > 0: - for record_parent_vertex in record_parent_vertices: - - parent_record = load_pam_record(params, - record_parent_vertex.uid) # type: TypedRecord | None - if parent_record is None: - print(f"{bcolors.FAIL} * Parent record {record_parent_vertex.uid} " - f"does not exists.{bcolors.ENDC}") - continue - - acl_edge = record_vertex.get_edge(record_parent_vertex, EdgeType.ACL) - if acl_edge is not None: - acl_content = acl_edge.content_as_object(UserAcl) # type: UserAcl - print(f" * ACL to {self._n(parent_record.record_type)}; {parent_record.title}; " - f"{record_parent_vertex.uid}") - print(f" . Raw JSON: {json.dumps(acl_edge.content_as_dict)}") - if acl_content.is_admin: - print(f" . Is {self._gr('Admin')}") - if acl_content.belongs_to: - print(f" . Belongs") - else: - print(f" . Is {self._bl('Remote user')}") - - if acl_content.rotation_settings is None: - print(f"{bcolors.FAIL} . There are no rotation settings!{bcolors.ENDC}") - else: - if (acl_content.rotation_settings.schedule is None - or acl_content.rotation_settings.schedule == ""): - print(f" . No Schedule") - else: - print(f" . Schedule = {acl_content.rotation_settings.get_schedule()}") - - if (acl_content.rotation_settings.pwd_complexity is None - or acl_content.rotation_settings.pwd_complexity == ""): - print(f" . No Password Complexity") - else: - key_bytes = record.record_key - print(f" . Password Complexity = " - f"{acl_content.rotation_settings.get_pwd_complexity(key_bytes)}") - print(f" . Disabled = {acl_content.rotation_settings.disabled}") - print(f" . NOOP = {acl_content.rotation_settings.noop}") - print(f" . SaaS configuration record UID = " - f"{acl_content.rotation_settings.saas_record_uid_list}") - - if len(acl_content.rotation_settings.saas_record_uid_list) > 0: - if acl_content.rotation_settings.noop: - saas_config_uid = acl_content.rotation_settings.saas_record_uid_list[0] - saas_config = load_pam_record( - params, - saas_config_uid) # type: TypedRecord | None - - print(f" . SaaS configuration record is {saas_config.title}") - else: - print(f"{bcolors.FAIL} . Has SaaS plugin config record, " - f"however it's not NOOP{bcolors.ENDC}") - - elif record.record_type == PAM_USER: - print(f"{bcolors.FAIL} * PAM User has NO acl!!!!!!{bcolors.ENDC}") - - link_edge = record_vertex.get_edge(record_parent_vertex, EdgeType.LINK) - if link_edge is not None: - print(f" * LINK to {self._n(parent_record.record_type)}; {parent_record.title}; " - f"{record_parent_vertex.uid}") - else: - # This really should not happen - print(f"{bcolors.FAIL} Record does not have a parent record.{bcolors.ENDC}") - print("") - - record_child_vertices = record_vertex.has_vertices() - print(self._b(" Child Records")) - if len(record_child_vertices) > 0: - for record_child_vertex in record_child_vertices: - child_record = load_pam_record(params, - record_child_vertex.uid) # type: TypedRecord | None - - if child_record is None: - print(f"{bcolors.FAIL} * Child record {record_child_vertex.uid} " - f"does not exists.{bcolors.ENDC}") - continue - - acl_edge = record_child_vertex.get_edge(record_vertex, EdgeType.ACL) - link_edge = record_child_vertex.get_edge(record_vertex, EdgeType.LINK) - if acl_edge is not None: - acl_content = acl_edge.content_as_object(UserAcl) - print(f" * ACL from {self._n(child_record.record_type)}; {child_record.title}; " - f"{record_child_vertex.uid}") - if acl_content.is_admin: - print(f" . Is {self._gr('Admin')}") - if acl_content.belongs_to: - print(f" . Belongs") - else: - print(f" . Is {self._bl('Remote user')}") - elif link_edge is not None: - print(f" * LINK from {self._n(child_record.record_type)}; {child_record.title}; " - "{record_child_vertex.uid}") - else: - for edge in record_vertex.edges: # List[DAGEdge] - print(f" * {self._f(edge.edge_type)}?") - - else: - # This is OK - print(f" Record does not have any children.") - print("") + print(self._h("Record Linking Graph")) + graph = PAMDebugGraphCommand() + graph.do_list(params=params, gateway_context=gateway_context, graph_type="rl", debug_level=0, + indent=1, show_data=True, record_uids=[record.record_uid]) else: print(f"{bcolors.FAIL}Cannot find record in record linking.{bcolors.ENDC}") diff --git a/keepercommander/discovery_common/__version__.py b/keepercommander/discovery_common/__version__.py index 5eba4ad50..f5e08a61e 100644 --- a/keepercommander/discovery_common/__version__.py +++ b/keepercommander/discovery_common/__version__.py @@ -1 +1 @@ -__version__ = '1.1.23' +__version__ = '1.1.25' diff --git a/keepercommander/discovery_common/record_link.py b/keepercommander/discovery_common/record_link.py index 89cb72824..947191a20 100644 --- a/keepercommander/discovery_common/record_link.py +++ b/keepercommander/discovery_common/record_link.py @@ -92,7 +92,8 @@ def dag(self) -> DAG: fail_on_corrupt=self.fail_on_corrupt, log_prefix=self.log_prefix, save_batch_count=self.save_batch_count, - agent=self.agent) + agent=self.agent, + path_aware=True) sync_point = self._dag.load(sync_point=0) self.logger.debug(f"the record linking sync point is {sync_point or 0}") if not self.dag.has_graph: diff --git a/keepercommander/discovery_common/rm_types.py b/keepercommander/discovery_common/rm_types.py index 6829da93a..1a2e62b9e 100644 --- a/keepercommander/discovery_common/rm_types.py +++ b/keepercommander/discovery_common/rm_types.py @@ -107,10 +107,12 @@ class RmMappedUser(BaseModel): :param id: Provider identifier for the user. :param name: Human-readable user name. :param role_ids: Ids of the roles this user holds (references RmMappedRole.id). + :param group_ids: Ids of the groups this user belongs to (references RmMappedGroup.id). """ id: str name: Optional[str] = None role_ids: List[str] = [] + group_ids: List[str] = [] class RmMappedGroup(BaseModel): diff --git a/keepercommander/keeper_dag/__version__.py b/keepercommander/keeper_dag/__version__.py index ed7133b36..f00ed5d16 100644 --- a/keepercommander/keeper_dag/__version__.py +++ b/keepercommander/keeper_dag/__version__.py @@ -1 +1 @@ -__version__ = '1.1.11' # pragma: no cover +__version__ = '1.2.0' # pragma: no cover diff --git a/keepercommander/keeper_dag/connection/__init__.py b/keepercommander/keeper_dag/connection/__init__.py index 24ef3ba66..19594a6e9 100644 --- a/keepercommander/keeper_dag/connection/__init__.py +++ b/keepercommander/keeper_dag/connection/__init__.py @@ -10,11 +10,13 @@ import os import time import sys +import json from enum import Enum from pydantic import BaseModel -from typing import Optional, Union, Any, Dict, Tuple, TYPE_CHECKING +from typing import Optional, Union, Any, Dict, Tuple, List, TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover Logger = Union[logging.RootLogger, logging.Logger] + from ..types import JitSettings, ConnectionSettingsBase, AiSettings, Meta # What is this? # If used with Commander, router_abbr_pb2 will interfere with router_pb2. @@ -218,6 +220,8 @@ def payload_and_headers(self, payload: Any) -> Tuple[Union[str, bytes], Dict]: self.logger.debug("payload is protobuf") headers = {'Content-Type': 'application/octet-stream'} payload = encrypt_aes(payload.SerializeToString(), self.transmission_key) + elif isinstance(payload, dict): + payload = json.dumps(payload) else: raise Exception("Cannot determine if the model is pydantic or protobuf.") @@ -468,3 +472,18 @@ def get_leafs(self, error=str(err) ) raise DAGException(f"Could not get leafs: {err}") + + def configure_resource(self, + record: Any, + configuration_record_uid: str, + connection_user_uids: List[str] = [], + admin_user_record_uid: Optional[str] = None, + connection_settings: Optional[ConnectionSettingsBase] = None, + jit_settings: Optional[JitSettings] = None, + ai_settings: Optional[AiSettings] = None, + domain_uid: Optional[str] = None, + meta: Optional[Dict] = None, + update_services: Optional[Meta] = None, + agent: Optional[str] = None): + + raise Exception(f"configure_resource does not exists for {self.__class__.__name__}") diff --git a/keepercommander/keeper_dag/connection/ksm.py b/keepercommander/keeper_dag/connection/ksm.py index b4ad0279f..78480af29 100644 --- a/keepercommander/keeper_dag/connection/ksm.py +++ b/keepercommander/keeper_dag/connection/ksm.py @@ -2,6 +2,10 @@ from . import ConnectionBase from ..utils import value_to_boolean from ..exceptions import DAGException, DAGConnectionException +from ..types import (JitSettings, AiSettings, Meta, ConnectionSettingsBase, NetworkSettings, NetworkResource, + NetworkRotation) +from ..crypto import encrypt_aes +from ..__version__ import __version__ from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import ec @@ -17,10 +21,12 @@ import logging import json +import base64 import os import requests import time -from typing import Union, Optional, Tuple, Dict, Any, TYPE_CHECKING +from pydantic import BaseModel +from typing import Union, Optional, Tuple, Dict, Any, List, TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover from keeper_secrets_manager_core.storage import KeyValueStorage @@ -57,7 +63,7 @@ def __init__(self, self.use_read_protobuf = False if self.use_write_protobuf: self.logger.info("KSM cannot use protobuf for writing to the graph, using JSON.") - self.use_write_protobuf = False + self.use_read_protobuf = False if InMemoryKeyValueStorage.is_base64(config): config = utils.base64_to_string(config) @@ -330,3 +336,432 @@ def rest_call_to_router(self, self.logger.info(f"will retry call after {retry_wait} seconds.") time.sleep(retry_wait) + + def configure_resource(self, + record: Record, + configuration_record_uid: str, + connection_user_uids: Optional[List[str]] = None, + admin_user_record_uid: Optional[str] = None, + connection_settings: Optional[ConnectionSettingsBase] = None, + jit_settings: Optional[JitSettings] = None, + ai_settings: Optional[AiSettings] = None, + domain_uid: Optional[str] = None, + meta: Optional[Meta] = None, + update_services: Optional[bool] = False, + agent: Optional[str] = None): + """ + Configure a resource in the PAM graph. + + After creating a V3 record in the Vault, this method is called to configure that record in hte PAM graph. + + `jit_settings` should be an instance of JitSettings. + It will also take JSON and a python dictionary for that model. + + `connection_settings` should be a child instance of ConnectionSettingsBase. + It will also take JSON and a python dictionary for that model. + + `meta` should be an instance of Meta. + It will also take JSON and a python dictionary for that model. + + `ai_settings` should be an instance of AiSettings. + It will also take JSON and a python dictionary for that model. + + :param record: A pamMachine, pamDatabase, or pamDirectory record. + :param configuration_record_uid: The configuration record uid the resource should be connected to. + :param connection_user_uids: List of user UID connected to this resource. + :param admin_user_record_uid: The user UID of the administrator of the resource. + :param connection_settings: Instance of ConnectionSettings. + :param jit_settings: Instance of JitSettings. + :param ai_settings: Instance of AiSettings. + :param domain_uid: Indicated this resource connects to a domain. + :param meta: Meta information stored on the DATA edge. + :param update_services: Allow rotation to update services on this resource. + :param agent: A custom HTTP agent. Shows up in Data Dog. + :return: + """ + + if record is None: + raise Exception("Record is None. Cannot set_record_rotation.") + + # only configure resources. + if record.type not in ["pamMachine", "pamDatabase", "pamDirectory"]: + raise ValueError(f"Cannot configure resources on {record.uid}. Record type is {record.type}.") + + if jit_settings is not None: + if isinstance(jit_settings, JitSettings): + jit_settings = jit_settings.model_dump_json() + elif isinstance(jit_settings, str): + try: + json.loads(jit_settings) + except Exception as err: + raise ValueError(f"The jit_settings is not JSON: {err}") + elif isinstance(jit_settings, dict): + jit_settings = json.dumps(jit_settings) + else: + raise ValueError("Unknown structure for jit_settings") + jit_settings = base64.b64encode(encrypt_aes(jit_settings.encode(), record.record_key_bytes)).decode() + + if connection_settings is not None: + self.logger.debug("currently, the Vault reads connection_settings from the record, not the graph.") + if isinstance(connection_settings, ConnectionSettingsBase): + connection_settings = connection_settings.model_dump_json() + elif isinstance(connection_settings, str): + try: + json.loads(connection_settings) + except Exception as err: + raise ValueError(f"The connection_settings is not JSON: {err}") + elif isinstance(connection_settings, dict): + connection_settings = json.dumps(connection_settings) + else: + raise ValueError("Unknown structure for connection_settings") + + if meta is not None: + if isinstance(meta, Meta): + meta = meta.model_dump_json() + elif isinstance(meta, str): + try: + json.loads(meta) + except Exception as err: + raise ValueError(f"The meta is not JSON: {err}") + elif isinstance(meta, dict): + meta = json.dumps(meta) + else: + raise ValueError("Unknown structure for meta") + + if ai_settings is not None: + if isinstance(ai_settings, AiSettings): + ai_settings = ai_settings.model_dump_json() + elif isinstance(ai_settings, str): + try: + json.loads(ai_settings) + except Exception as err: + raise ValueError(f"The ai_settings is not JSON: {err}") + elif isinstance(ai_settings, dict): + ai_settings = json.dumps(ai_settings) + else: + raise ValueError("Unknown structure for ai_settings") + ai_settings = base64.b64encode(encrypt_aes(ai_settings.encode(), record.record_key_bytes)).decode() + + if agent is None: + agent = f"keeper-dag/{__version__}" + + endpoint = self._endpoint("configure_resource") + self.logger.debug(f"endpoint {endpoint}") + + try: + payload = dict( + recordUid=record.uid, + networkUid=configuration_record_uid, + adminUid=admin_user_record_uid, + meta=meta, + connectionSettings=connection_settings, + connectUsers=connection_user_uids, + domainUid=domain_uid, + jitSettings=jit_settings, + updateServices=update_services, + keeperAiSettings=ai_settings + ) + + print(json.dumps(payload)) + + payload, headers = self.payload_and_headers(payload) + self.rest_call_to_router(http_method="POST", + endpoint=endpoint, + payload=payload, + headers=headers, + agent=agent) + except Exception as err: + raise DAGException(f"Could not configure resource for {record.uid}: {err}") + + def set_record_rotation(self, + record: Record, + configuration_record_uid: str, + schedule: Optional[dict | str] = None, + resource_uid: Optional[str] = None, + pwd_complexity: Optional[dict] = None, + disabled: bool = False, + remote_address: Optional[str] = None, + noop: bool = False, + saas_configuration_uid: Optional[str] = None, + update_services: bool = False, + service_resource_uids: Optional[List[str]] = None, + service_names: Optional[List] = None, + agent: Optional[str] = None): + + """ + Set the rotation setting on a PAM User record. + + The `schedule` can either be a dictionary or JSON. + The `pwd_complexity` can either be a dictionary or JSON. + + The service names is a list of resource UID and names. + The resource UID should be found in the `service_resource_uids` list. + For example, + + Examples: + service_names = [ + { + "resource_uid": "ztcAMLHaIU_uUK6lYK0GNw", + "names": [ + { + "type": "service", + "items": [{"name": "Service Name", "via_discovery": True}] + } + ] + } + ] + + `names` can also be a list of `UserAclServiceNames` from `discovery-common`. + + :param record: The PAM User record to set rotation settings. + :param configuration_record_uid: The configuration record UID. + :param schedule: If set, password rotation will happen on a schedule. + :param resource_uid: If set, the user will belong to this resource. + :param pwd_complexity: The complexity of the password. + :param disabled: Is True, rotation will not be allowed. + :param remote_address: The IP address of the machine making the request. + :param noop: If True, user account are not updated during rotations. + :param saas_configuration_uid: If set, this user is used on a remote SaaS. + :param update_services: If True, if the user is used for a services, + those services password will also be rotated. + :param service_resource_uids: A list of resource UID where this user is used for a service. + :param service_names: List of dictionaries containing the resource UID, from service_resource_uids, and + and and a base64, encrypted, JSON. + :param agent: A custom HTTP agent. Shows up in Data Dog. + :return: + """ + + if record is None: + raise Exception("Record is None. Cannot set_record_rotation.") + + # Only set the rotation setting on PAM User records. + if record.type != "pamUser": + raise ValueError(f"Cannot set the rotation setting on {record.uid}. Record type is {record.type}.") + + if schedule is None: + schedule = "" + else: + if isinstance(schedule, dict): + schedule = json.dumps(schedule) + if isinstance(schedule, str): + try: + json.loads(schedule) + except Exception as err: + raise ValueError(f"The schedules is not JSON: {err}") + + if pwd_complexity is None: + pwd_complexity = {} + if isinstance(pwd_complexity, dict): + pwd_complexity = json.dumps(pwd_complexity) + if isinstance(pwd_complexity, str): + pwd_complexity = pwd_complexity.encode() + if not isinstance(pwd_complexity, bytes): + raise ValueError("The complexity is not a dictionary, string or bytes.") + pwd_complexity = base64.b64encode(encrypt_aes(pwd_complexity, record.record_key_bytes)).decode() + + if agent is None: + agent = f"keeper-dag/{__version__}" + + endpoint = self._endpoint("set_record_rotation") + self.logger.debug(f"endpoint {endpoint}") + + try: + payload = dict( + recordUid=record.uid, + revision=record.revision, + networkUid=configuration_record_uid, + disabled=disabled, + schedules=schedule, + pwdComplexity=pwd_complexity, + remoteAddress=remote_address, + noop=noop, + updateServices=update_services + ) + if resource_uid is not None: + payload["resourceUid"] = resource_uid + if saas_configuration_uid is not None: + payload["saasConfiguration"] = saas_configuration_uid + if service_resource_uids is not None and isinstance(service_resource_uids, list): + payload["serviceResources"] = service_resource_uids + if service_names is not None: + if not isinstance(service_names, list): + raise ValueError("service_names should be a list of objects that contain the resourceUID and name.") + + payload["serviceNames"] = [] + + for item in service_names: + + name = item.get("names") + if isinstance(name, BaseModel): + name = name.model_dump() + elif isinstance(name, str): + try: + name = json.loads(name) + except (Exception,): + raise ValueError("The name is not JSON.") + elif isinstance(name, list): + name = json.dumps(name) + else: + raise ValueError(f"The name is not a list, string or pydantic model.") + name = base64.b64encode(encrypt_aes(name.encode(), record.record_key_bytes)).decode() + + payload["serviceNames"].append( + { + "names": name, + "resourceUid": item.get("resource_uid") + } + ) + + payload, headers = self.payload_and_headers(payload) + + print(payload) + + self.rest_call_to_router(http_method="POST", + endpoint=endpoint, + payload=payload, + headers=headers, + agent=agent) + except Exception as err: + raise DAGException(f"Could not set the record's rotation setting for {record.uid}: {err}") + + def configure_network_graph(self, + configuration_record_uid: str, + network_settings: Optional[NetworkSettings] = None, + resources: Optional[List[NetworkResource]] = None, + rotations: Optional[List[NetworkRotation]] = None, + agent: Optional[str] = None): + + """ + Bulk configure network/configuraton. + + :param configuration_record_uid: The UID of the configuration record. + :param network_settings: Instance of NetworkSettings + :param resources: + :param rotations: + :param agent: + :return: + """ + + if agent is None: + agent = f"keeper-dag/{__version__}" + + endpoint = self._endpoint("configure_network_graph") + self.logger.debug(f"endpoint {endpoint}") + + try: + payload: Dict[str, Any] = { + "recordUid": configuration_record_uid + } + + if network_settings is not None: + if isinstance(network_settings, NetworkSettings): + network_settings = network_settings.model_dump() + elif isinstance(network_settings, str): + try: + network_settings = json.loads(network_settings) + except Exception as err: + raise ValueError(f"The network_settings is not JSON: {err}") + else: + raise ValueError("Unknown structure for network_settings") + + payload["networkSettings"] = network_settings + + if resources is not None: + + if not isinstance(resources, list): + raise ValueError("resources is not a list") + + encoded_resources = [] + for item in resources: + item.networkUid = configuration_record_uid + + # If we have meta, make sure its JSON + if item.meta: + if isinstance(item.meta, Meta): + item.meta = item.meta.encode() + elif isinstance(item.meta, dict): + item.meta = json.dumps(item.meta) + elif isinstance(item.meta, str): + try: + json.loads(item.meta) + except Exception as err: + raise ValueError(f"The meta for {item.recordUid} is not JSON: {err}") + + # If we have connectionSettings, make sure its JSON + if item.connectionSettings: + if isinstance(item.connectionSettings, ConnectionSettingsBase): + item.connectionSettings = item.connectionSettings.encode() + elif isinstance(item.connectionSettings, dict): + item.connectionSettings = json.dumps(item.connectionSettings) + elif isinstance(item.connectionSettings, str): + try: + json.loads(item.connectionSettings) + except Exception as err: + raise ValueError(f"The connectionSettings for {item.recordUid} is not JSON: {err}") + + # For jitSettings we need the encrypted JSON as Base64. + # We cannot encrypt in here since we don't have the record key bytes. + if item.jitSettings: + if isinstance(item.jitSettings, str): + try: + json.loads(item.jitSettings) + raise ValueError(f"The jitSettings for {item.recordUid} is JSON, " + "should be base64 str.") + except (Exception,): + pass + else: + raise ValueError(f"The jitSettings for {item.recordUid} is not a base64 str.") + + # For keeperAiSettings we need the encrypted JSON as Base64. + # We cannot encrypt in here since we don't have the record key bytes. + if item.keeperAiSettings: + if isinstance(item.keeperAiSettings, str): + try: + json.loads(item.keeperAiSettings) + raise ValueError(f"The keeperAiSettings for {item.recordUid} is JSON, " + "should be base64 str.") + except (Exception,): + pass + else: + raise ValueError(f"The keeperAiSettings for {item.recordUid} is not a base64 str.") + + encoded_resources.append(item.model_dump(exclude_none=True)) + payload["resources"] = encoded_resources + + if rotations is not None: + + if not isinstance(rotations, list): + raise ValueError("rotations is not a list") + + encoded_rotations = [] + for item in rotations: + item.networkUid = configuration_record_uid + + if item.schedule: + if isinstance(item.schedule, str): + try: + json.loads(item.schedule) + except (Exception,): + raise ValueError(f"The schedule for {item.recordUid} is not valid JSON.") + else: + raise ValueError(f"The schedule for {item.recordUid} is not JSON.") + + encoded_rotations.append(item.model_dump(exclude_none=True)) + + payload["rotations"] = encoded_rotations + + payload, headers = self.payload_and_headers(payload) + + print(payload) + + self.rest_call_to_router(http_method="POST", + endpoint=endpoint, + payload=payload, + headers=headers, + agent=agent) + except Exception as err: + raise DAGException(f"Could not configure network graph for {configuration_record_uid}: {err}") + + + + diff --git a/keepercommander/keeper_dag/dag.py b/keepercommander/keeper_dag/dag.py index a9066528e..ff6685a15 100644 --- a/keepercommander/keeper_dag/dag.py +++ b/keepercommander/keeper_dag/dag.py @@ -60,7 +60,9 @@ def __init__(self, data_requires_encryption: bool = False, log_prefix: str = "GraphSync", save_batch_count: Optional[int] = None, + path_aware: bool = False, agent: Optional[str] = None, + dedup_edges: bool = False): """ @@ -85,6 +87,7 @@ def __init__(self, :param data_requires_encryption: Data edges are already encrypted. Default is False. :param log_prefix: Text prepended to the log messages. Handy if dealing with multiple graphs. :param save_batch_count: The number of edges to save at one time. + :param path_aware: Use path when managing edges. :param agent: User Agent to send with web service requests. :param dedup_edges: Remove modified edges if the same edge added before save. :return: Instance of GraphSync @@ -105,6 +108,9 @@ def __init__(self, self.dedup_edge = value_to_boolean(os.environ.get("GS_DEDUP_EDGES", dedup_edges)) self.dedup_edge_warning = value_to_boolean(os.environ.get("GS_DEDUP_EDGES_WARN", False)) + # If True, edges will use their type and the path as a unqiue key. + self.path_aware = path_aware + if self.dedup_edge and auto_save: raise Exception("Cannot run dedup_edge and auto_save at the same time. The dedup_edge feature only works " "in bulk saves.") @@ -703,7 +709,9 @@ def _load(self, sync_point: int = 0): f"edge type {edge_type}", level=3) if edge_type == EdgeType.DELETION: - tail.disconnect_from(head) + tail.disconnect_from(vertex=head, + path=data.path, + path_aware=self.path_aware) else: content = data.content if content is not None: @@ -711,16 +719,18 @@ def _load(self, sync_point: int = 0): content = str_to_bytes(content) # Connect this vertex to the head vertex. It belongs to that head vertex. - tail.belongs_to( - vertex=head, - edge_type=edge_type, - content=content, - # ACL and LINK edges are not encrypted. - is_encrypted=False, - path=data.path, - modified=False, - from_load=True - ) + if tail and head: + tail.belongs_to( + vertex=head, + edge_type=edge_type, + content=content, + # ACL and LINK edges are not encrypted. + is_encrypted=False, + path_aware=self.path_aware, + path=data.path, + modified=False, + from_load=True + ) self.debug("", level=2) self.debug(" PROCESS the DATA edges", level=2) @@ -759,6 +769,7 @@ def _load(self, sync_point: int = 0): content=content, # Assume DATA is encrypted; it might not be but, we will handle that later. is_encrypted=True, + path_aware=self.path_aware, path=data.path, modified=False, from_load=True, diff --git a/keepercommander/keeper_dag/edge.py b/keepercommander/keeper_dag/edge.py index ccc0faaa1..83ba53aa4 100644 --- a/keepercommander/keeper_dag/edge.py +++ b/keepercommander/keeper_dag/edge.py @@ -21,6 +21,7 @@ def __init__(self, head_uid: str, version: int = 0, content: Optional[Any] = None, + path_aware: Optional[bool] = None, path: Optional[str] = None, modified: bool = True, block_content_auto_save: bool = False, @@ -37,7 +38,8 @@ def __init__(self, :param head_uid: The vertex uid that has this edge's vertex. The vertex uid that the edge arrow points at. :param version: Version of this edge. :param content: The content of this edge. - :param path: Short tag about this edge. Do + :param ignore_path: + :param path: Short tag about this edge. :param modified: :param block_content_auto_save: :param from_load: Is this being called from the load() method? @@ -61,6 +63,9 @@ def __init__(self, # We want to only save the newest duplicated edge, so skip prior ones. self.skip_on_save: bool = False + # If an edge need to be filtered, should the path be used? + self.path_aware = path_aware + # Block auto save in the content setter. # When creating an edge, don't save until the edge is added to the edge list. self.block_content_auto_save = block_content_auto_save diff --git a/keepercommander/keeper_dag/types.py b/keepercommander/keeper_dag/types.py index 4b614fbfe..f969909fd 100644 --- a/keepercommander/keeper_dag/types.py +++ b/keepercommander/keeper_dag/types.py @@ -1,7 +1,10 @@ from __future__ import annotations +from .crypto import encrypt_aes from enum import Enum -from pydantic import BaseModel -from typing import List, Optional, Union +import base64 +import json +from pydantic import BaseModel, ConfigDict +from typing import List, Optional, Union, Dict class BaseEnum(Enum): @@ -188,3 +191,351 @@ class DataPayload(BaseModel): origin: Ref dataList: List graphId: Optional[int] = 0 + + +class ConnectionProtocolEnum(BaseEnum): + ssh = "ssh" + rdp = "rdp" + vnc = "vnc" + telnet = "telnet" + http = "http" + sqlserver = "sqlserver" + postgresql = "postgresql" + mysql = "mysql" + + +class ConnectionSecurity(BaseEnum): + any = "any" + nla = "nla" + tls = "tls" + rdp = "rdp" + + +class ConnectionSettingsBase(BaseModel): + protocol: ConnectionProtocolEnum + port: Optional[str] = None + allowSupplyUser: bool = False + userRecords: List[str] = [] + recordingIncludeKeys: bool = False + + def encode(self) -> str: + return self.model_dump_json() + + +class ConnectionSettingsSftp(BaseModel): + enableSftp: bool = True + sftpRootDirectory: Optional[str] = None + sftpServerAliveInterval: Optional[int] = 30 + + +class ConnectionSettingsSsh(ConnectionSettingsBase): + protocol: ConnectionProtocolEnum = ConnectionProtocolEnum.ssh + port: str = "22" + allowSupplyUser: bool = True + disableCopy: bool = False + disablePaste: bool = False + colorScheme: Optional[str] = "white-black" + fontSize: Optional[int] = 12 + scrollback: Optional[int] = 2000 + hostKey: Optional[str] = None + command: Optional[str] = None + sftp: Optional[ConnectionSettingsSftp] = ConnectionSettingsSftp() + + +class ConnectionSettingsRdp(ConnectionSettingsBase): + protocol: ConnectionProtocolEnum = ConnectionProtocolEnum.rdp + port: str = "3389" + disableCopy: bool = False + disablePaste: bool = False + security: ConnectionSecurity = ConnectionSecurity.any + disableAuth: bool = False + ignoreCert: bool = True + loadBalanceInfo: Optional[str] = None + sftp: Optional[ConnectionSettingsSftp] = ConnectionSettingsSftp() + disableAudio: bool = False + resizeMethod: str = "display-update" + enableWallpaper: bool = False + enableFullWindowDrag: bool = False + + +class ConnectionSettingsHttp(ConnectionSettingsBase): + protocol: ConnectionProtocolEnum = ConnectionProtocolEnum.http + port: str = "443" + disableCopy: bool = False + disablePaste: bool = False + + +class JitSettings(BaseModel): + createEphemeral: bool = False + elevate: bool = False + elevationMethod: str = "group" + elevationString: Optional[str] = None + baseDistinguishedName: Optional[str] = None + ephemeralAccountType: Optional[str] = None + + def encode(self, record_key_bytes: bytes) -> str: + return base64.b64encode(encrypt_aes(self.model_dump_json().encode(), record_key_bytes)).decode() + + +class AiSettingsRiskTagItemLog(BaseModel): + date: int + userId: str + action: str + + +class AiSettingsRiskTagItem(BaseModel): + tag: str + auditLog: List[AiSettingsRiskTagItemLog] = [] + + +class AiSettingsRiskTag(BaseModel): + allow: List[AiSettingsRiskTagItem] = [] + deny: List[AiSettingsRiskTagItem] = [] + + +class AiSettingsRiskLevel(BaseModel): + aiSessionTerminate: bool = False + tags: AiSettingsRiskTag = AiSettingsRiskTag() + + +class AiSettingsRiskLevels(BaseModel): + low: AiSettingsRiskLevel = AiSettingsRiskLevel() + medium: AiSettingsRiskLevel = AiSettingsRiskLevel() + high: AiSettingsRiskLevel = AiSettingsRiskLevel() + critical: AiSettingsRiskLevel = AiSettingsRiskLevel() + + +class AiSettings(BaseModel): + version: str = "v1.0.0" + riskLevels: AiSettingsRiskLevels = AiSettingsRiskLevels() + + def encode(self, record_key_bytes: bytes) -> str: + return base64.b64encode(encrypt_aes(self.model_dump_json().encode(), record_key_bytes)).decode() + + +class MetaAllSettings(BaseModel): + remoteBrowserIsolation: bool = False + rotation: bool = False + connections: bool = False + portForwards: bool = False + sessionRecording: bool = False + typescriptRecording: bool = False + aiEnabled: bool = False + aiSessionTerminate: bool = False + + +class Meta(BaseModel): + allowedSettings: MetaAllSettings = MetaAllSettings() + idpConfigUid: Optional[str] = None + rotateOnTermination: bool = False + version: int = 1 + locked: bool = False + no_update_services: bool = False + defaultElevationTime: int = 3600000 + + def encode(self) -> str: + return self.model_dump_json() + + +class PortForward(BaseModel): + port: str + reusePort: bool = False + useSpecifiedLocalPort: bool = False + localPort: Optional[str] = None + + def encode(self) -> str: + return self.model_dump_json() + + +class NetworkSettings(BaseModel): + allowedSettings: MetaAllSettings = MetaAllSettings() + idpConfigUid: Optional[str] = None + adminUid: Optional[str] = None + + def encode(self) -> str: + return self.model_dump_json() + + +class NetworkResource(BaseModel): + model_config = ConfigDict(extra='allow') + + recordUid: str + adminUid: Optional[str] = None + meta: Optional[str] = None + connectionSettings: Optional[str] = None + connectUsers: Optional[List[str]] = None + domainUid: Optional[str] = None + jitSettings: Optional[str] = None + keeperAiSettings: Optional[str] = None + updateServices: Optional[bool] = None + + +class NetworkRotation(BaseModel): + model_config = ConfigDict(extra='allow') + + recordUid: str + revision: int + configurationUid: Optional[str] = None + resourceUid: Optional[str] = None + schedule: Optional[str] = None + pwdComplexity: Optional[str] = None + disabled: Optional[bool] = None + updateServices: Optional[bool] = None + serviceResources: Optional[List[str]] = None + serviceNames: Optional[List[Dict]] = None + + +class PasswordComplexity(BaseModel): + length: int = 20 + caps: int = 1 + lowercase: int = 1 + digits: int = 1 + special: int = 1 + specialChars: str = """!@#$%^?();',.=+[]<>{}-_/\\*&:"`~|""" + + def encode(self, record_key_bytes: bytes) -> str: + return base64.b64encode(encrypt_aes(self.model_dump_json().encode(), record_key_bytes)).decode() + +# https://keeper.atlassian.net/wiki/spaces/EPD/pages/3970793540/Rotation+Schedule + + +class ScheduleDow(BaseEnum): + SUNDAY = "SUNDAY" + MONDAY = "MONDAY" + TUESDAY = "TUESDAY" + WEDNESDAY = "WEDNESDAY" + THURSDAY = "THURSDAY" + FRIDAY = "FRIDAY" + SATURDAY = "SATURDAY" + + +class ScheduleOccurrence(BaseEnum): + FIRST = "FIRST" + SECOND = "SECOND" + THIRD = "THIRD" + FOURTH = "FOURTH" + LAST = "LAST" + + +class ScheduleMonth(BaseEnum): + JANUARY = "JANUARY" + FEBRUARY = "FEBRUARY" + MARCH = "MARCH" + APRIL = "APRIL" + MAY = "MAY" + JUNE = "JUNE" + JULY = "JULY" + AUGUST = "AUGUST" + SEPTEMBER = "SEPTEMBER" + OCTOBER = "OCTOBER" + NOVEMBER = "NOVEMBER" + DECEMBER = "DECEMBER" + + +class Schedule(BaseModel): + type: str = "NA" + + def encode(self) -> str: + return json.dumps([self.model_dump(exclude_none=True)]) + + +class ScheduleCron(Schedule): + type: str = "CRON" + cron: str + tz: Optional[str] = None + + +class ScheduleRunOnce(Schedule): + type: str = "RUN_ONCE" + time: str + tz: Optional[str] = None + + +class ScheduleHourly(Schedule): + type: str = "HOURLY" + minute: Optional[int] = None + second: Optional[int] = None + intervalCount: int = 1 + + +class ScheduleDaily(Schedule): + type: str = "DAILY" + time: str + tz: Optional[str] = None + intervalCount: Optional[int] = None + + +class ScheduleWeekly(Schedule): + type: str = "WEEKLY" + weekday: ScheduleDow + time: str + tz: Optional[str] = None + intervalCount: Optional[int] = None + + +class ScheduleMonthByDay(Schedule): + type: str = "MONTHLY_BY_DAY" + monthDay: int + time: str + tz: Optional[str] = None + intervalCount: Optional[int] = None + + +class ScheduleMonthByWeekday(Schedule): + type: str = "MONTHLY_BY_WEEKDAY" + weekday: ScheduleDow + occurrence: ScheduleOccurrence + time: str + tz: Optional[str] = None + intervalCount: Optional[int] = None + + +class ScheduleYearly(Schedule): + type: str = "YEARLY" + month: ScheduleMonth + monthDay: int + time: str + intervalCount: int = 1 + tz: Optional[str] = None + +#################### + + +class ServiceEnum(BaseEnum): + service = "service" + task = "task" + iis_pool = "iis_pool" + dcom = "dcom" + com = "com" + com_plus = "com_plus" + scom = "scom" + + +class ServiceNameItem(BaseModel): + name: str + + # If this was added via Discovery, this will be True + via_discovery: bool = False + + +class ServiceName(BaseModel): + type: ServiceEnum + items: List[ServiceNameItem] = [] + + +class ServiceResourceName(BaseModel): + resourceUid: str + names: List[ServiceName] + + def encode(self, record_key_bytes: bytes) -> Dict: + + names = [] + for item in self.names: + names.append(item.model_dump(mode='json')) + + print(names) + + return { + "resourceUid": self.resourceUid, + "names": base64.b64encode(encrypt_aes(json.dumps(names).encode(), record_key_bytes)).decode() + } \ No newline at end of file diff --git a/keepercommander/keeper_dag/vertex.py b/keepercommander/keeper_dag/vertex.py index 48c45d0c8..944df6c51 100644 --- a/keepercommander/keeper_dag/vertex.py +++ b/keepercommander/keeper_dag/vertex.py @@ -222,55 +222,107 @@ def uid(self): """ return self._uid - def get_edge(self, vertex: DAGVertex, edge_type: EdgeType) -> DAGEdge: + def get_edge(self, + vertex: DAGVertex, + edge_type: EdgeType, + path_aware: Optional[bool] = None, + path: Optional[str] = None) -> Optional[DAGEdge]: + + """ + Get an edge + :param vertex: The vertex where the tail connects. + :param edge_type: The edge type enumeration. + :param path_aware: If True, use the path as a filter. + :param path: Path to match. None will return edges without a path. + :return: An optional DAGEdge instance. + """ + + if path_aware is None: + path_aware = self.dag.path_aware + high_edge = None high_version = -1 for edge in self.edges: - # Get all the edge point at the same vertex. - # Don't include DATA edges. - if edge.head_uid == vertex.uid and edge.edge_type == edge_type: - if edge.version > high_version: - high_version = edge.version - high_edge = edge + if (edge is not None + and edge.head_uid == vertex.uid + and edge.edge_type == edge_type + and edge.version > high_version + and (not path_aware + or (path is None and edge.path is None) + or (path is not None and path == edge.path))): + high_version = edge.version + high_edge = edge return high_edge - def get_highest_edge_version(self, head_uid: str) -> Tuple[int, Optional[DAGEdge]]: + def get_highest_edge_version(self, + head_uid: str, + path_aware: Optional[bool] = None, + path: Optional[str] = None) -> Tuple[int, Optional[DAGEdge]]: """ - Find the highest DAGEdge version of all edge types. + Find the highest DAGEdge version of all edge types and paths. - :param head_uid: + :param head_uid: Where the edge is towards. + :param path_aware: If True, use the path as a filter. + :param path: Path to match. None will return edges without a path. :return: """ + if path_aware is None: + path_aware = self.dag.path_aware + high_edge = None high_version = -1 for edge in self.edges: - # Get all the edge point at the same vertex. - # Don't include DATA edges. - if edge.head_uid == head_uid: - if edge.version > high_version: - high_edge = edge - high_version = edge.version + if (edge is not None + and edge.head_uid == head_uid + and edge.version > high_version + and (not path_aware or path == edge.path)): + high_edge = edge + high_version = edge.version return high_version, high_edge - def edge_count(self, vertex: DAGVertex, edge_type: EdgeType) -> int: + def edge_count(self, + vertex: DAGVertex, + edge_type: EdgeType, + path_aware: Optional[bool] = None, + path: Optional[str] = None) -> int: """ Get the number of edges between two vertices. :param vertex: :param edge_type: + :param path_aware: + :param path: :return: """ + + if path_aware is None: + path_aware = self.dag.path_aware + count = 0 for edge in self.edges: - if edge.head_uid == vertex.uid and edge.edge_type == edge_type: + if (edge is not None + and edge.head_uid == vertex.uid + and edge.edge_type == edge_type + and (not path_aware or path == edge.path)): count += 1 return count - def edge_by_type(self, vertex: DAGVertex, edge_type: EdgeType) -> List[DAGEdge]: + def edge_by_type(self, + vertex: DAGVertex, + edge_type: EdgeType, + path_aware: Optional[bool] = None, + path: Optional[str] = None) -> List[DAGEdge]: + + if path_aware is None: + path_aware = self.dag.path_aware + edge_list = [] for edge in self.edges: - if edge.edge_type == edge_type and edge.head_uid == vertex.uid: + if (edge is not None + and edge.edge_type == edge_type + and edge.head_uid == vertex.uid + and (not path_aware or path == edge.path)): edge_list.append(edge) return edge_list @@ -278,19 +330,44 @@ def edge_by_type(self, vertex: DAGVertex, edge_type: EdgeType) -> List[DAGEdge]: def has_data(self) -> bool: """ - Does this vertex contain a DATA edge? + Does this vertex contain a DATA edge regardless of path? + + **DEPRECATED** - Use `has_data_for_path` :return: True if vertex has a DATA edge. """ for item in self.edges: - if item.edge_type == EdgeType.DATA: + if item is not None and item.edge_type == EdgeType.DATA: + return True + return False + + def has_data_for_path(self, + path: Optional[str] = None) -> bool: + + """ + Does this vertex contain a DATA edge for the provided path? + + With check if a DAGA edge exists for the path. + If the path is None, it will check if a DATA edge exists where the path is None. + + :param path: Optional path. + :return: True if vertex has a DATA edge for the path. + """ + + for item in self.edges: + if (item is not None + and item.edge_type == EdgeType.DATA + and path == item.path): return True return False - def get_data(self, index: Optional[int] = None) -> Optional[DAGEdge]: + def get_data(self, + path_aware: Optional[bool] = None, + path: Optional[str] = None, + index: Optional[int] = None) -> Optional[DAGEdge]: """ - Get data edge + Get data edge. If the index is None or 0, the latest data edge will be returned. A positive and negative, non-zero, index will return the same data. @@ -299,11 +376,19 @@ def get_data(self, index: Optional[int] = None) -> Optional[DAGEdge]: If there is no data, None is returned. - :param index: + :param path_aware: If True, path will be used as a filter. + :param index: If history allows, get prior data. + :param path: If path_aware is True, find data that matches the path. :return: """ - data_list = self.edge_by_type(self, EdgeType.DATA) + if path_aware is None: + path_aware = self.dag.path_aware + + data_list = self.edge_by_type(self, + edge_type=EdgeType.DATA, + path=path, + path_aware=path_aware) data_count = len(data_list) if data_count == 0: return None @@ -331,6 +416,7 @@ def add_data(self, content: Any, is_encrypted: bool = False, is_serialized: bool = False, + path_aware: Optional[bool] = None, path: Optional[str] = None, modified: bool = True, from_load: bool = False, @@ -342,6 +428,7 @@ def add_data(self, :param content: The content to store in the DATA edge. :param is_encrypted: Is the content encrypted? :param is_serialized: Is the content base64 serialized? + :param path_aware: If True, path will be used as a filter. :param path: Simple string tag to identify the edge. :param modified: Does this modify the content? By default, adding a DATA edge will flag that the edge has been modified. @@ -354,6 +441,9 @@ def add_data(self, self.debug(f"connect {self.uid} to DATA edge", level=1) + if path_aware is None: + path_aware = self.dag.path_aware + # Are we trying to add DATA to a deleted vertex? if not self.active: @@ -384,7 +474,7 @@ def add_data(self, # Get the prior data, set the version and inactive the prior data. version = 0 - prior_data = self.get_data() + prior_data = self.get_data(path=path, path_aware=path_aware) if prior_data is not None: version = prior_data.version + 1 prior_data.active = False @@ -406,6 +496,7 @@ def add_data(self, head_uid=self.uid, version=version, content=content, + path_aware=path_aware, path=path, modified=modified, is_serialized=is_serialized, @@ -418,7 +509,7 @@ def add_data(self, # The history level is per edge type. # It's FIFO, so we will remove the first edge type if we exceed the history level. if self.dag.history_level > 0: - data_count = self.data_count() + data_count = self.data_count(path=path, path_aware=path_aware) while data_count > self.dag.history_level: for index in range(0, len(self.edges) - 1): if self.edges[index].edge_type == EdgeType.DATA: @@ -428,14 +519,29 @@ def add_data(self, self.dag.do_auto_save() - def data_count(self): - return self.edge_count(self, EdgeType.DATA) + def data_count(self, + path_aware: Optional[bool] = None, + path: Optional[str] = None): + + return self.edge_count(self, + path_aware=path_aware, + path=path, + edge_type=EdgeType.DATA) - def data_delete(self): + def data_delete(self, + path_aware: Optional[bool] = None, + path: Optional[str] = None): + + if path_aware is None: + path_aware = self.dag.path_aware # Get the DATA edge. # It will be a reference to itself. - data_edge = self.get_edge(self, EdgeType.DATA) + data_edge = self.get_edge(self, + edge_type=EdgeType.DATA, + path=path, + path_aware=path_aware) + if data_edge is None: self.debug("cannot delete the data, no data edge exists.") @@ -443,26 +549,61 @@ def data_delete(self): self.belongs_to( vertex=self, - edge_type=EdgeType.DELETION + edge_type=EdgeType.DELETION, + path_aware=path_aware, + path=path ) - self.debug(f"deleted data edge for {self.uid}") + self.debug(f"deleted data edge for {self.uid}; path {path}") @property - def latest_data_version(self): + def latest_data_version(self, + path_aware: Optional[bool] = None, + path: Optional[str] = None): + """ + Get the latest DATA edge version. + + :param path_aware: + :param path: + :return: + """ + + if path_aware is None: + path_aware = self.dag.path_aware + version = -1 for edge in self.edges: - if edge.edge_type == EdgeType.DATA and edge.version > version: + # If edge is defined; and the paths match, and the edge if a DATA type; and the edge version is > + if (edge is not None + and (not path_aware or path == edge.path) + and edge.edge_type == EdgeType.DATA and edge.version > version): version = edge.version return version @property def content(self) -> Optional[Union[str, bytes]]: """ - Get the content of the active DATA edge. + Get the content of the first found, active DATA edge for any path. + + If the content is a str, then the content is encrypted. + + **DEPRECATED**: use `content_for_path` + """ + data_edge = self.get_data(path=None, path_aware=False) + if data_edge is None: + return None + return data_edge.content + + def content_for_path(self, + path: Optional[str] = None) -> Optional[Union[str, bytes]]: + """ + Get the content of the active DATA edge for a path. If the content is a str, then the content is encrypted. + + :param path: Get content for a specific path. + :return: Content as a str or bytes. """ - data_edge = self.get_data() + data_edge = self.get_data(path=path, path_aware=self.dag.path_aware) if data_edge is None: return None return data_edge.content @@ -470,10 +611,25 @@ def content(self) -> Optional[Union[str, bytes]]: @property def content_as_dict(self) -> Optional[dict]: """ - Get the content from the active DATA edge as a dictionary. + Get the content from the first found, active DATA edge, for any path, as a dictionary. :return: Content as a dictionary. + + **DEPRECATED**: use `content_as_dict_for_path` """ - data_edge = self.get_data() + data_edge = self.get_data(path=None, path_aware=False) + if data_edge is None: + return None + return data_edge.content_as_dict + + def content_as_dict_for_path(self, + path: Optional[str] = None) -> Optional[dict]: + """ + Get the content from the active DATA edge, for a path, as a dictionary. + + :param path: Get content for a specific path. + :return: Content as a dictionary. + """ + data_edge = self.get_data(path=path, path_aware=self.dag.path_aware) if data_edge is None: return None return data_edge.content_as_dict @@ -481,23 +637,47 @@ def content_as_dict(self) -> Optional[dict]: @property def content_as_str(self) -> Optional[str]: """ - Get the content from the active DATA edge as a str. + Get the content from the first found, active DATA edge, for any path, as a str. + + **DEPRECATED**: use `content_as_str_for_path` + + :return: Content as a str. + """ + + data_edge = self.get_data(path=None, path_aware=False) + if data_edge is None: + return None + return data_edge.content_as_str + + def content_as_str_for_path(self, + path: Optional[str] = None) -> Optional[str]: + """ + Get the content from the active DATA edge, with no path, as a str. :return: Content as a str. """ - data_edge = self.get_data() + data_edge = self.get_data(path=path, path_aware=self.dag.path_aware) if data_edge is None: return None return data_edge.content_as_str - def content_as_object(self, meta_class: Type[T]) -> Optional[T]: + def content_as_object(self, + meta_class: Type[T], + path_aware: Optional[bool] = False, + path: Optional[str] = None) -> Optional[T]: """ Get the content as a pydantic based object. :param meta_class: The class to return + :param path_aware: If True, path will be used as a filter. + :param path: Find a DATA edge that matches this path. None with match None. :return: """ - data_edge = self.get_data() + + if path_aware is None: + path_aware = self.dag.path_aware + + data_edge = self.get_data(path=path, path_aware=path_aware) if data_edge is None: return None @@ -507,13 +687,19 @@ def content_as_object(self, meta_class: Type[T]) -> Optional[T]: def has_key(self) -> bool: """ - Does this vertex contain any KEY or ACL edges? + Does this vertex contain any KEY? + + If `path_aware` is enabled, the KEY must not have a path. + If not enabled, it will take the first path it finds regardless of path. + This will be the active KEY. - :return: True if vertex has a KEY or ACL edge. + :return: True if vertex has a KEY edge where the path is None. """ for item in self.edges: - if item.edge_type == EdgeType.KEY: + if (item + and item.edge_type == EdgeType.KEY + and (not self.dag.path_aware or item.path is None)): return True return False @@ -522,6 +708,7 @@ def belongs_to(self, edge_type: EdgeType, content: Optional[Any] = None, is_encrypted: bool = False, + path_aware: Optional[bool] = None, path: Optional[str] = None, modified: bool = True, from_load: bool = False): @@ -539,6 +726,7 @@ def belongs_to(self, :param edge_type: The edge type that connects the two vertices. :param content: Data to store as the edges content. :param is_encrypted: Is the content encrypted? + :param path_aware: If True, the path will be used as a filter. :param path: Text tag for the edge. :param modified: Does adding this edge modify the stored DAG? :param from_load: Is being connected from load() method? @@ -547,8 +735,12 @@ def belongs_to(self, self.debug(f"connect {self.uid} to {vertex.uid} with edge type {edge_type.value}", level=1) + if path_aware is None: + path_aware = self.dag.path_aware + if vertex is None: raise ValueError("Vertex is blank.") + if self.uid == self.dag.uid and not (edge_type == EdgeType.DATA or edge_type == EdgeType.DELETION): if not from_load: raise DAGIllegalEdgeException(f"Cannot create edge to self for edge type {edge_type}.") @@ -568,13 +760,18 @@ def belongs_to(self, # Figure out what version of the edge we are. - version, version_edge = self.get_highest_edge_version(head_uid=vertex.uid) + version, version_edge = self.get_highest_edge_version(head_uid=str(vertex.uid), + path=path, + path_aware=path_aware) # If the new edge is not DELETION if edge_type != EdgeType.DELETION: # Find the current active edge for this edge type to make it inactive. - current_edge_by_type = self.get_edge(vertex, edge_type) + current_edge_by_type = self.get_edge(vertex=vertex, + edge_type=edge_type, + path_aware=path_aware, + path=path) if current_edge_by_type is not None: current_edge_by_type.active = False @@ -589,7 +786,10 @@ def belongs_to(self, self.dag.debug_stacktrace() # If we are adding a non-DELETION edge, it will inactivate the DELETION edge. - highest_deletion_edge = self.get_edge(vertex, EdgeType.DELETION) + highest_deletion_edge = self.get_edge(vertex=vertex, + edge_type=EdgeType.DELETION, + path_aware=path_aware, + path=path) if highest_deletion_edge is not None: highest_deletion_edge.active = False @@ -604,7 +804,7 @@ def belongs_to(self, if edge_type == EdgeType.DELETION: return - if self.dag.dedup_edge and version_edge.modified: + if self.dag.dedup_edge and version_edge is not None and version_edge.modified: version_edge.skip_on_save = True if self.dag.dedup_edge_warning: self.dag.debug("edge was deleted in session, will not save DELETION edge") @@ -624,6 +824,7 @@ def belongs_to(self, block_content_auto_save=True, content=content, is_encrypted=is_encrypted, + path_aware=path_aware, path=path, modified=modified ) @@ -637,18 +838,27 @@ def belongs_to(self, def belongs_to_root(self, edge_type: EdgeType, - path: Optional[str] = None): + path_aware: Optional[bool] = None, + path: Optional[str] = None, + content: Optional[Any] = None, + is_encrypted: bool = False): """ Connect the vertex to the root vertex. :param edge_type: The type of edge to use for the connection. + :param path_aware: If True, the path will be used as a filter. :param path: Short tag for this edge. + :param content: Data to store as the edges content. + :param is_encrypted: Is the content encrypted? :return: """ self.debug(f"connect {self.uid} to root", level=1) + if path_aware is None: + path_aware = self.dag.path_aware + if self.uid == self.dag.uid: raise DAGIllegalEdgeException("Cannot create edge to self.") @@ -657,16 +867,29 @@ def belongs_to_root(self, # We are adding the root, we can enable auto save now. # We can get the correct stream id with an edge to the root vertex. - self.belongs_to(self.dag.get_root, edge_type=edge_type, path=path) - - self.dag.allow_auto_save = True - self.dag.do_auto_save() - - def has_vertices(self, edge_type: Optional[EdgeType] = None, allow_inactive: bool = False, + root = self.dag.get_root + if root: + self.belongs_to(vertex=root, + edge_type=edge_type, + path=path, + path_aware=path_aware, + content=content, + is_encrypted=is_encrypted) + + self.dag.allow_auto_save = True + self.dag.do_auto_save() + + def has_vertices(self, + edge_type: Optional[EdgeType] = None, + allow_inactive: bool = False, allow_self_ref: bool = False) -> List[DAGVertex]: """ Get a list of vertices that belong to this vertex. + + :param edge_type: Filter for a specific edge type. + :param allow_inactive: If True, include vertices that are inactive. + :param allow_self_ref: If True, allow vertices that refer to themselves. :return: List of DAGVertex """ @@ -679,23 +902,26 @@ def has_vertices(self, edge_type: Optional[EdgeType] = None, allow_inactive: boo continue vertex = self.dag.get_vertex(uid) - if edge_type is not None: - edge = vertex.get_edge(self, edge_type=edge_type) - if edge is not None: - vertices.append(vertex) - - # If no edge type was specified, do not return DATA and DELETION. - # Also do not include vertices that are inactive by default. - elif edge_type != EdgeType.DATA and edge_type != EdgeType.DELETION: - if vertex.active is True or allow_inactive is True: - vertices.append(vertex) + if vertex: + if edge_type is not None: + edge = vertex.get_edge(self, edge_type=edge_type) + if edge is not None: + vertices.append(vertex) + + # If no edge type was specified, do not return DATA and DELETION. + # Also do not include vertices that are inactive by default. + elif edge_type != EdgeType.DATA and edge_type != EdgeType.DELETION: + if vertex.active is True or allow_inactive is True: + vertices.append(vertex) return vertices - def has(self, vertex: DAGVertex, edge_type: Optional[EdgeType] = None) -> bool: + def has(self, + vertex: DAGVertex, + edge_type: Optional[EdgeType] = None) -> bool: """ - Does this vertex have the passed in vertex? + Does "self" have the vertex passed in? :return: True if request vertex belongs to this vertex. False if it does not. @@ -713,12 +939,12 @@ def belongs_to_vertices(self) -> List[DAGVertex]: vertices = [] for edge in self.edges: # If the edge is not a DATA or DELETION type, and the edge is the highest version/active - if edge.edge_type != EdgeType.DATA and edge.edge_type != EdgeType.DELETION and edge.active is True: + if edge and edge.edge_type != EdgeType.DATA and edge.edge_type != EdgeType.DELETION and edge.active is True: # The head will point at the remote vertex. # If it is active, and not already in the list, add it to the list of vertices this vertex belongs to. vertex = self.dag.get_vertex(edge.head_uid) - if vertex.active is True and vertex not in vertices: + if vertex and vertex.active is True and vertex not in vertices: vertices.append(vertex) return vertices @@ -736,7 +962,10 @@ def belongs_to_a_vertex(self) -> bool: return len(self.belongs_to_vertices()) > 0 - def disconnect_from(self, vertex: DAGVertex, path: Optional[str] = None): + def disconnect_from(self, + vertex: DAGVertex, + path_aware: Optional[bool] = None, + path: Optional[str] = None): """ Disconnect this vertex from another vertex. @@ -745,6 +974,7 @@ def disconnect_from(self, vertex: DAGVertex, path: Optional[str] = None): If the vertex no longer belongs to another vertex, the vertex will be deleted. :param vertex: The vertex this vertex belongs to + :param path_aware: If True, the path will be used a specific edge. :param path: an Optional path for the DELETION edge. :return: """ @@ -752,15 +982,21 @@ def disconnect_from(self, vertex: DAGVertex, path: Optional[str] = None): if vertex is None: raise ValueError("Vertex is blank.") + if path_aware is None: + path_aware = self.dag.path_aware + # Flag all the edges as inactive. for edge in self.edges: - if edge.head_uid == vertex.uid and edge.edge_type: + if (edge is not None + and (not path_aware or edge.path == path) + and edge.head_uid == vertex.uid): edge.active = False # Add the DELETION edge self.belongs_to( vertex=vertex, edge_type=EdgeType.DELETION, + path_aware=path_aware, path=path ) @@ -768,12 +1004,17 @@ def disconnect_from(self, vertex: DAGVertex, path: Optional[str] = None): # There is no longer a KEY edge to decrypt the DATA. has_active_key_edge = False for edge in self.edges: - if edge.edge_type == EdgeType.KEY and edge.active is True: + if (edge is not None + and (not path_aware or path == edge.path) + and edge.edge_type == EdgeType.KEY + and edge.active is True): has_active_key_edge = True break if not has_active_key_edge: for edge in self.edges: - if edge.edge_type == EdgeType.DATA: + if (edge is not None + and (not path_aware or path == edge.path) + and edge.edge_type == EdgeType.DATA): edge.active = False if not self.belongs_to_a_vertex: @@ -875,7 +1116,7 @@ def walk_down_path(self, path: Union[str, List[str]]) -> Optional[DAGVertex]: self.debug(f"vertex {self.uid} has {vertex.uid}", level=2) for edge in vertex.edges: # If the edge matches the current path, the head of the edge is this vertex, a route exists. - if edge.path == current_path and edge.head_uid == self.uid: + if edge and edge.path == current_path and edge.head_uid == self.uid: # If there is no path left, this is our vertex if len(path) == 0: return vertex @@ -893,7 +1134,7 @@ def get_paths(self) -> List[str]: paths = [] for vertex in self.has_vertices(): for edge in vertex.edges: - if edge.path is None or edge.path == "": + if edge and edge.path is None or edge.path == "": continue paths.append(edge.path)