diff --git a/packages/one-c-normalizer/src/one_c_normalizer/__init__.py b/packages/one-c-normalizer/src/one_c_normalizer/__init__.py
index 0fc449b..a4e1dca 100644
--- a/packages/one-c-normalizer/src/one_c_normalizer/__init__.py
+++ b/packages/one-c-normalizer/src/one_c_normalizer/__init__.py
@@ -71,6 +71,49 @@ class Rights(ObjectPart):
permissions: dict = {}
+class AccessRoleAssignment(BaseModel):
+ role: str
+ role_qualified_name: str | None = None
+ source: str | None = None
+ attributes: dict = {}
+
+
+class AccessProfile(BaseModel):
+ name: str
+ qualified_name: str | None = None
+ source_path: str | None = None
+ attributes: dict = {}
+ roles: list[AccessRoleAssignment] = []
+
+
+class AccessGroup(BaseModel):
+ name: str
+ qualified_name: str | None = None
+ source_path: str | None = None
+ profile: str | None = None
+ profile_qualified_name: str | None = None
+ attributes: dict = {}
+ roles: list[AccessRoleAssignment] = []
+ users: list[str] = []
+
+
+class AccessUser(BaseModel):
+ name: str
+ qualified_name: str | None = None
+ source_path: str | None = None
+ full_name: str | None = None
+ disabled: bool = False
+ attributes: dict = {}
+ roles: list[AccessRoleAssignment] = []
+ groups: list[str] = []
+
+
+class AccessModel(BaseModel):
+ profiles: list[AccessProfile] = []
+ groups: list[AccessGroup] = []
+ users: list[AccessUser] = []
+
+
class Extension(ObjectPart):
kind: str = "EXTENSION"
version: str | None = None
@@ -137,6 +180,7 @@ class NormalizedProject(BaseModel):
project_id: str | None = None
configuration: ConfigurationRoot
source_path: str | None = None
+ access: AccessModel = Field(default_factory=AccessModel)
def normalize_bsl_source(text: str) -> str:
@@ -207,6 +251,9 @@ def build_normalized_project(
extension_metadata_objects: dict[str, dict[str, MetadataObject]] = {}
part_owners: dict[str, tuple[MetadataObject, ObjectPart]] = {}
pending_roles: dict[str, Role] = {}
+ access_profiles: dict[str, AccessProfile] = {}
+ access_groups: dict[str, AccessGroup] = {}
+ access_users: dict[str, AccessUser] = {}
extensions: list[Extension] = []
extension_by_qualified_name: dict[str, Extension] = {}
saw_configuration = False
@@ -248,6 +295,19 @@ def build_normalized_project(
metadata=dict(item.attributes),
rights=role.rights,
)
+ elif item.object_kind == "ACCESS_PROFILE":
+ profile = _access_profile_from_item(item)
+ access_profiles[_access_key(profile.qualified_name, profile.name)] = profile
+ elif item.object_kind == "ACCESS_GROUP":
+ group = _access_group_from_item(item)
+ access_groups[_access_key(group.qualified_name, group.name)] = group
+ elif item.object_kind == "ACCESS_USER":
+ user = _access_user_from_item(item)
+ access_users[_access_key(user.qualified_name, user.name)] = user
+ elif item.object_kind == "ACCESS_ROLE_ASSIGNMENT":
+ _attach_access_role_assignment(item, access_profiles, access_groups, access_users)
+ elif item.object_kind == "ACCESS_GROUP_MEMBERSHIP":
+ _attach_access_group_membership(item, access_groups, access_users)
elif item.object_kind in _ROOT_METADATA_OBJECT_KINDS:
target_objects = _metadata_target_for_item(
item,
@@ -292,6 +352,11 @@ def build_normalized_project(
return NormalizedProject(
project_id=project_id,
source_path=source_path,
+ access=AccessModel(
+ profiles=sorted(access_profiles.values(), key=lambda item: item.name.casefold()),
+ groups=sorted(access_groups.values(), key=lambda item: item.name.casefold()),
+ users=sorted(access_users.values(), key=lambda item: item.name.casefold()),
+ ),
configuration=ConfigurationRoot(
name=configuration_name,
metadata=configuration_metadata,
@@ -344,6 +409,143 @@ def _all_metadata_objects(
return result
+def _access_key(qualified_name: str | None, name: str) -> str:
+ return (qualified_name or name).casefold()
+
+
+def _access_role_assignment_from_attributes(attributes: dict, *, source: str | None = None) -> AccessRoleAssignment | None:
+ role = _first_attr(attributes, "role", "Role", "Роль", "roleName", "ИмяРоли")
+ if not role:
+ return None
+ role_qualified_name = role if "." in role else f"Роль.{role}"
+ return AccessRoleAssignment(
+ role=role.split(".")[-1],
+ role_qualified_name=role_qualified_name,
+ source=source or _first_attr(attributes, "source", "Source", "Источник"),
+ attributes=dict(attributes),
+ )
+
+
+def _access_profile_from_item(item: OneCXmlObject) -> AccessProfile:
+ attributes = dict(item.attributes)
+ profile = AccessProfile(
+ name=item.name,
+ qualified_name=item.qualified_name,
+ source_path=item.source_path,
+ attributes=attributes,
+ )
+ for role in _split_attr_list(attributes, "roles", "Roles", "Роли"):
+ assignment = _access_role_assignment_from_attributes({"role": role}, source=item.qualified_name)
+ if assignment is not None:
+ profile.roles.append(assignment)
+ return profile
+
+
+def _access_group_from_item(item: OneCXmlObject) -> AccessGroup:
+ attributes = dict(item.attributes)
+ group = AccessGroup(
+ name=item.name,
+ qualified_name=item.qualified_name,
+ source_path=item.source_path,
+ profile=_first_attr(attributes, "profile", "Profile", "Профиль", "accessProfile", "ПрофильГруппыДоступа"),
+ profile_qualified_name=_first_attr(attributes, "profileQualifiedName", "ProfileQualifiedName", "ПрофильПолноеИмя"),
+ attributes=attributes,
+ )
+ for role in _split_attr_list(attributes, "roles", "Roles", "Роли"):
+ assignment = _access_role_assignment_from_attributes({"role": role}, source=item.qualified_name)
+ if assignment is not None:
+ group.roles.append(assignment)
+ group.users.extend(_split_attr_list(attributes, "users", "Users", "Пользователи", "members", "Members", "Участники"))
+ return group
+
+
+def _access_user_from_item(item: OneCXmlObject) -> AccessUser:
+ attributes = dict(item.attributes)
+ user = AccessUser(
+ name=item.name,
+ qualified_name=item.qualified_name,
+ source_path=item.source_path,
+ full_name=_first_attr(attributes, "fullName", "FullName", "ПолноеИмя", "full_name"),
+ disabled=_truthy(_first_attr(attributes, "disabled", "Disabled", "Недействителен", "isDisabled")),
+ attributes=attributes,
+ )
+ for role in _split_attr_list(attributes, "roles", "Roles", "Роли"):
+ assignment = _access_role_assignment_from_attributes({"role": role}, source=item.qualified_name)
+ if assignment is not None:
+ user.roles.append(assignment)
+ user.groups.extend(_split_attr_list(attributes, "groups", "Groups", "Группы", "accessGroups", "ГруппыДоступа"))
+ return user
+
+
+def _attach_access_role_assignment(
+ item: OneCXmlObject,
+ profiles: dict[str, AccessProfile],
+ groups: dict[str, AccessGroup],
+ users: dict[str, AccessUser],
+) -> None:
+ assignment = _access_role_assignment_from_attributes(item.attributes, source=item.qualified_name)
+ if assignment is None:
+ return
+ owner = _first_attr(item.attributes, "owner", "Owner", "Владелец", "profile", "Profile", "Профиль", "group", "Group", "Группа", "user", "User", "Пользователь")
+ owner_key = owner.casefold() if owner else item.qualified_name.rsplit(".", 1)[0].casefold()
+ for collection in (profiles, groups, users):
+ target = collection.get(owner_key)
+ if target is None:
+ target = next(
+ (
+ value
+ for value in collection.values()
+ if value.name.casefold() == owner_key or str(value.qualified_name or "").casefold() == owner_key
+ ),
+ None,
+ )
+ if target is not None:
+ target.roles.append(assignment)
+ return
+
+
+def _attach_access_group_membership(
+ item: OneCXmlObject,
+ groups: dict[str, AccessGroup],
+ users: dict[str, AccessUser],
+) -> None:
+ group_name = _first_attr(item.attributes, "group", "Group", "Группа", "accessGroup", "ГруппаДоступа")
+ user_name = _first_attr(item.attributes, "user", "User", "Пользователь", "member", "Member", "Участник")
+ if not group_name or not user_name:
+ return
+ group = groups.get(group_name.casefold()) or next(
+ (value for value in groups.values() if value.name.casefold() == group_name.casefold()),
+ None,
+ )
+ user = users.get(user_name.casefold()) or next(
+ (value for value in users.values() if value.name.casefold() == user_name.casefold()),
+ None,
+ )
+ if group is not None and user_name not in group.users:
+ group.users.append(user_name)
+ if user is not None and group_name not in user.groups:
+ user.groups.append(group_name)
+
+
+def _first_attr(attributes: dict, *keys: str) -> str:
+ for key in keys:
+ value = attributes.get(key)
+ if value not in (None, ""):
+ return str(value)
+ return ""
+
+
+def _split_attr_list(attributes: dict, *keys: str) -> list[str]:
+ value = _first_attr(attributes, *keys)
+ if not value:
+ return []
+ return [item.strip() for item in re.split(r"[,;\n]", value) if item.strip()]
+
+
+def _truthy(value: str) -> bool:
+ return value.casefold() in {"true", "1", "yes", "да", "истина"}
+
+
def _walk_xml_objects(
source_path: str,
element: ET.Element,
@@ -361,6 +563,14 @@ def _walk_xml_objects(
right = _xml_right_object(source_path, element, role_context)
if right is not None:
result.append(right)
+ elif object_kind == "ACCESS_ROLE_ASSIGNMENT":
+ assignment = _xml_access_role_assignment(source_path, element, parent_qualified_name)
+ if assignment is not None:
+ result.append(assignment)
+ elif object_kind == "ACCESS_GROUP_MEMBERSHIP":
+ membership = _xml_access_group_membership(source_path, element, parent_qualified_name)
+ if membership is not None:
+ result.append(membership)
elif object_kind is not None:
name = _xml_name(element, source_path=source_path)
if name:
@@ -430,6 +640,56 @@ def _xml_role_reference(element: ET.Element) -> str:
return ""
+def _xml_access_role_assignment(
+ source_path: str,
+ element: ET.Element,
+ parent_qualified_name: str | None,
+) -> OneCXmlObject | None:
+ attributes = _xml_attributes(element)
+ role = _first_attr(attributes, "role", "Role", "Роль", "name", "Name", "Имя")
+ if not role:
+ text = (element.text or "").strip()
+ role = text if text else ""
+ if not role:
+ return None
+ owner = _first_attr(attributes, "profile", "Profile", "Профиль", "group", "Group", "Группа", "user", "User", "Пользователь")
+ if not owner and parent_qualified_name:
+ owner = parent_qualified_name
+ attributes.setdefault("role", role)
+ if owner:
+ attributes.setdefault("owner", owner)
+ return OneCXmlObject(
+ source_path=source_path,
+ object_kind="ACCESS_ROLE_ASSIGNMENT",
+ name=role,
+ qualified_name=f"{owner}.{role}" if owner else role,
+ attributes=attributes,
+ )
+
+
+def _xml_access_group_membership(
+ source_path: str,
+ element: ET.Element,
+ parent_qualified_name: str | None,
+) -> OneCXmlObject | None:
+ attributes = _xml_attributes(element)
+ user = _first_attr(attributes, "user", "User", "Пользователь", "member", "Member", "Участник", "name", "Name", "Имя")
+ group = _first_attr(attributes, "group", "Group", "Группа", "accessGroup", "ГруппаДоступа")
+ if not group and parent_qualified_name:
+ group = parent_qualified_name
+ if not user or not group:
+ return None
+ attributes.setdefault("user", user)
+ attributes.setdefault("group", group)
+ return OneCXmlObject(
+ source_path=source_path,
+ object_kind="ACCESS_GROUP_MEMBERSHIP",
+ name=user,
+ qualified_name=f"{group}.{user}",
+ attributes=attributes,
+ )
+
+
_OBJECT_KIND_BY_TAG = {
"configuration": "PROJECT",
"конфигурация": "PROJECT",
@@ -540,6 +800,41 @@ _OBJECT_KIND_BY_TAG = {
"пакетxdto": "XDTO_PACKAGE",
"role": "ROLE",
"роль": "ROLE",
+ "accessprofile": "ACCESS_PROFILE",
+ "accessprofiles": "ACCESS_PROFILE",
+ "accessgroupprofile": "ACCESS_PROFILE",
+ "accessgroupprofiles": "ACCESS_PROFILE",
+ "профильгруппыдоступа": "ACCESS_PROFILE",
+ "профилигруппдоступа": "ACCESS_PROFILE",
+ "accessgroup": "ACCESS_GROUP",
+ "accessgroups": "ACCESS_GROUP",
+ "группадоступа": "ACCESS_GROUP",
+ "группыдоступа": "ACCESS_GROUP",
+ "infobaseuser": "ACCESS_USER",
+ "infobaseusers": "ACCESS_USER",
+ "accessuser": "ACCESS_USER",
+ "accessusers": "ACCESS_USER",
+ "user": "ACCESS_USER",
+ "users": "ACCESS_USER",
+ "пользователь": "ACCESS_USER",
+ "пользователи": "ACCESS_USER",
+ "roleassignment": "ACCESS_ROLE_ASSIGNMENT",
+ "roleassignments": "ACCESS_ROLE_ASSIGNMENT",
+ "accessrole": "ACCESS_ROLE_ASSIGNMENT",
+ "accessroles": "ACCESS_ROLE_ASSIGNMENT",
+ "profilerole": "ACCESS_ROLE_ASSIGNMENT",
+ "grouprole": "ACCESS_ROLE_ASSIGNMENT",
+ "userrole": "ACCESS_ROLE_ASSIGNMENT",
+ "рольдоступа": "ACCESS_ROLE_ASSIGNMENT",
+ "роли": "ACCESS_ROLE_ASSIGNMENT",
+ "member": "ACCESS_GROUP_MEMBERSHIP",
+ "members": "ACCESS_GROUP_MEMBERSHIP",
+ "membership": "ACCESS_GROUP_MEMBERSHIP",
+ "memberships": "ACCESS_GROUP_MEMBERSHIP",
+ "participant": "ACCESS_GROUP_MEMBERSHIP",
+ "participants": "ACCESS_GROUP_MEMBERSHIP",
+ "участник": "ACCESS_GROUP_MEMBERSHIP",
+ "участники": "ACCESS_GROUP_MEMBERSHIP",
"sessionparameter": "SESSION_PARAMETER",
"sessionparameters": "SESSION_PARAMETER",
"параметрсеанса": "SESSION_PARAMETER",
@@ -779,6 +1074,9 @@ _QUALIFIED_PREFIX_BY_KIND = {
"STYLE_ITEM": "ЭлементСтиля",
"STYLE": "Стиль",
"LANGUAGE": "Язык",
+ "ACCESS_PROFILE": "ПрофильГруппыДоступа",
+ "ACCESS_GROUP": "ГруппаДоступа",
+ "ACCESS_USER": "Пользователь",
"FORM": "Форма",
"COMMAND": "Команда",
"URL_TEMPLATE": "ШаблонURL",
@@ -1349,6 +1647,19 @@ def _xml_object_kind(element: ET.Element, *, parent_object_kind: str | None = No
tag = _local_name(element.tag).lower()
if parent_object_kind in {"FORM", "ELEMENT"} and tag in _FORM_ELEMENT_TAGS and _xml_name(element):
return "ELEMENT"
+ if parent_object_kind in {"ACCESS_PROFILE", "ACCESS_GROUP", "ACCESS_USER"} and tag in {
+ "role",
+ "roles",
+ "роль",
+ "роли",
+ "accessrole",
+ "profilerole",
+ "grouprole",
+ "userrole",
+ }:
+ return "ACCESS_ROLE_ASSIGNMENT"
+ if parent_object_kind == "ACCESS_GROUP" and tag in {"member", "members", "user", "users", "участник", "участники", "пользователь", "пользователи"}:
+ return "ACCESS_GROUP_MEMBERSHIP"
if tag in {"metadataobject", "object"}:
type_name = _xml_type_name(element)
if type_name:
@@ -1614,6 +1925,11 @@ def _read_text_file(path: Path) -> str:
__all__ = [
"COMMON_BRANCH_CHILDREN",
+ "AccessGroup",
+ "AccessModel",
+ "AccessProfile",
+ "AccessRoleAssignment",
+ "AccessUser",
"Command",
"ConfigurationRoot",
"Extension",
diff --git a/packages/one-c-normalizer/tests/test_normalizer.py b/packages/one-c-normalizer/tests/test_normalizer.py
index a596863..d02a73e 100644
--- a/packages/one-c-normalizer/tests/test_normalizer.py
+++ b/packages/one-c-normalizer/tests/test_normalizer.py
@@ -256,6 +256,33 @@ def test_normalize_edt_project_knows_full_common_metadata_catalog(tmp_path: Path
}.issubset(objects)
+def test_normalize_project_loads_access_profiles_groups_and_users(tmp_path: Path):
+ xml = tmp_path / "access.xml"
+ xml.write_text(
+ """
+
+
+
+
+
+
+
+
+
+
+""",
+ encoding="utf-8",
+ )
+
+ normalized = normalize_one_c_project(tmp_path, project_id="access-data")
+
+ assert normalized.access.profiles[0].name == "МенеджерПродаж"
+ assert normalized.access.profiles[0].roles[0].role_qualified_name == "Роль.ЧтениеПродаж"
+ assert normalized.access.groups[0].profile == "МенеджерПродаж"
+ assert normalized.access.groups[0].users == ["ivanov"]
+ assert normalized.access.users[0].full_name == "Иванов Иван"
+
+
def test_normalize_edt_project_preserves_localized_descriptions(tmp_path: Path):
catalog = tmp_path / "Контрагенты.mdo"
catalog.write_text(
diff --git a/packages/semantic-kernel/src/semantic_kernel/__init__.py b/packages/semantic-kernel/src/semantic_kernel/__init__.py
index c6caf56..0c18d24 100644
--- a/packages/semantic-kernel/src/semantic_kernel/__init__.py
+++ b/packages/semantic-kernel/src/semantic_kernel/__init__.py
@@ -106,6 +106,9 @@ _METADATA_OWNER_KINDS = {
NodeKind.STYLE_ITEM,
NodeKind.STYLE,
NodeKind.LANGUAGE,
+ NodeKind.ACCESS_PROFILE,
+ NodeKind.ACCESS_GROUP,
+ NodeKind.ACCESS_USER,
NodeKind.XDTO_PACKAGE,
NodeKind.EXTENSION,
NodeKind.ROLE,
@@ -293,6 +296,8 @@ def index_project(path: str | Path, *, project_id: str | None = None, structure_
command_nodes: list[SemanticNode] = []
form_nodes: list[SemanticNode] = []
role_rights: list[dict] = []
+ access_role_assignments: list[dict] = []
+ access_group_memberships: list[dict] = []
for source_file in source_files:
text = _read_text_file(source_file)
@@ -400,6 +405,12 @@ def index_project(path: str | Path, *, project_id: str | None = None, structure_
if xml_object.object_kind == "RIGHT":
role_rights.append(xml_object.attributes)
continue
+ if xml_object.object_kind == "ACCESS_ROLE_ASSIGNMENT":
+ access_role_assignments.append(xml_object.attributes)
+ continue
+ if xml_object.object_kind == "ACCESS_GROUP_MEMBERSHIP":
+ access_group_memberships.append(xml_object.attributes)
+ continue
kind = _xml_node_kind(xml_object.object_kind)
if kind is None:
continue
@@ -472,6 +483,9 @@ def index_project(path: str | Path, *, project_id: str | None = None, structure_
NodeKind.LANGUAGE,
NodeKind.XDTO_PACKAGE,
NodeKind.EXTENSION,
+ NodeKind.ACCESS_PROFILE,
+ NodeKind.ACCESS_GROUP,
+ NodeKind.ACCESS_USER,
NodeKind.ROLE,
NodeKind.FORM,
NodeKind.TABULAR_SECTION,
@@ -480,6 +494,8 @@ def index_project(path: str | Path, *, project_id: str | None = None, structure_
edges.extend(_link_metadata_to_modules(root, module_nodes, metadata_nodes, form_nodes))
edges.extend(_link_role_rights(nodes, role_rights))
+ edges.extend(_link_access_role_assignments(nodes, access_role_assignments))
+ edges.extend(_link_access_group_memberships(nodes, access_group_memberships))
edges.extend(_link_scheduled_jobs_to_routines(scheduled_job_nodes, routine_by_name))
edges.extend(_link_commands_to_handlers(command_nodes, routine_by_name))
edges.extend(_link_forms_to_handlers(form_nodes, routine_by_name))
@@ -1109,6 +1125,9 @@ def _xml_node_kind(object_kind: str) -> NodeKind | None:
"STYLE_ITEM": NodeKind.STYLE_ITEM,
"STYLE": NodeKind.STYLE,
"LANGUAGE": NodeKind.LANGUAGE,
+ "ACCESS_PROFILE": NodeKind.ACCESS_PROFILE,
+ "ACCESS_GROUP": NodeKind.ACCESS_GROUP,
+ "ACCESS_USER": NodeKind.ACCESS_USER,
"XDTO_PACKAGE": NodeKind.XDTO_PACKAGE,
"EXTENSION": NodeKind.EXTENSION,
"LAYOUT": NodeKind.LAYOUT,
@@ -1313,6 +1332,57 @@ def _link_role_rights(nodes: list[SemanticNode], role_rights: list[dict]) -> lis
return edges
+def _link_access_role_assignments(nodes: list[SemanticNode], assignments: list[dict]) -> list[SemanticEdge]:
+ if not assignments:
+ return []
+ by_qualified = {_normalize_lookup_key(node.qualified_name): node for node in nodes}
+ by_name_kind = {
+ (node.kind, _normalize_lookup_key(node.name)): node
+ for node in nodes
+ if node.kind in {NodeKind.ACCESS_PROFILE, NodeKind.ACCESS_GROUP, NodeKind.ACCESS_USER, NodeKind.ROLE}
+ }
+ edges: list[SemanticEdge] = []
+ for assignment in assignments:
+ owner_name = str(assignment.get("owner") or assignment.get("profile") or assignment.get("group") or assignment.get("user") or "")
+ role_name = str(assignment.get("role") or assignment.get("Role") or assignment.get("Роль") or "")
+ if not owner_name or not role_name:
+ continue
+ owner = by_qualified.get(_normalize_lookup_key(owner_name)) or next(
+ (
+ by_name_kind.get((kind, _normalize_lookup_key(owner_name)))
+ for kind in (NodeKind.ACCESS_PROFILE, NodeKind.ACCESS_GROUP, NodeKind.ACCESS_USER)
+ if by_name_kind.get((kind, _normalize_lookup_key(owner_name))) is not None
+ ),
+ None,
+ )
+ role = by_qualified.get(_normalize_lookup_key(role_name)) or by_qualified.get(_normalize_lookup_key(f"Роль.{role_name}")) or by_name_kind.get((NodeKind.ROLE, _normalize_lookup_key(role_name)))
+ if owner is None or role is None:
+ continue
+ edges.append(_edge(EdgeKind.ASSIGNS_ROLE, owner, role, owner.source_ref.source_path, 1, dict(assignment)))
+ return edges
+
+
+def _link_access_group_memberships(nodes: list[SemanticNode], memberships: list[dict]) -> list[SemanticEdge]:
+ if not memberships:
+ return []
+ by_qualified = {_normalize_lookup_key(node.qualified_name): node for node in nodes}
+ by_name_kind = {
+ (node.kind, _normalize_lookup_key(node.name)): node
+ for node in nodes
+ if node.kind in {NodeKind.ACCESS_GROUP, NodeKind.ACCESS_USER}
+ }
+ edges: list[SemanticEdge] = []
+ for membership in memberships:
+ group_name = str(membership.get("group") or membership.get("Group") or membership.get("Группа") or "")
+ user_name = str(membership.get("user") or membership.get("User") or membership.get("Пользователь") or "")
+ group = by_qualified.get(_normalize_lookup_key(group_name)) or by_name_kind.get((NodeKind.ACCESS_GROUP, _normalize_lookup_key(group_name)))
+ user = by_qualified.get(_normalize_lookup_key(user_name)) or by_name_kind.get((NodeKind.ACCESS_USER, _normalize_lookup_key(user_name)))
+ if group is None or user is None:
+ continue
+ edges.append(_edge(EdgeKind.MEMBER_OF, user, group, user.source_ref.source_path, 1, dict(membership)))
+ return edges
+
+
def _link_scheduled_jobs_to_routines(
scheduled_jobs: list[SemanticNode],
routine_by_name: dict[str, SemanticNode],
diff --git a/packages/semantic-kernel/tests/test_xml_indexing.py b/packages/semantic-kernel/tests/test_xml_indexing.py
index b566d75..9b63bab 100644
--- a/packages/semantic-kernel/tests/test_xml_indexing.py
+++ b/packages/semantic-kernel/tests/test_xml_indexing.py
@@ -107,6 +107,33 @@ def test_index_project_keeps_extended_1c_metadata_objects(tmp_path: Path):
}.issubset(by_kind)
+def test_index_project_links_access_profiles_groups_and_users(tmp_path: Path):
+ xml = tmp_path / "access.xml"
+ xml.write_text(
+ """
+
+
+
+
+
+
+
+
+
+
+""",
+ encoding="utf-8",
+ )
+
+ snapshot = index_project(tmp_path, project_id="access-graph")
+
+ assert any(node.kind == NodeKind.ACCESS_PROFILE and node.name == "МенеджерПродаж" for node in snapshot.nodes)
+ assert any(node.kind == NodeKind.ACCESS_GROUP and node.name == "ОтделПродаж" for node in snapshot.nodes)
+ assert any(node.kind == NodeKind.ACCESS_USER and node.name == "ivanov" for node in snapshot.nodes)
+ assert any(edge.kind == EdgeKind.ASSIGNS_ROLE for edge in snapshot.edges)
+ assert any(edge.kind == EdgeKind.MEMBER_OF for edge in snapshot.edges)
+
+
def test_index_project_remaps_edges_from_duplicate_metadata_nodes(tmp_path: Path):
first = tmp_path / "first.xml"
first.write_text(
diff --git a/packages/sir/src/sir/enums.py b/packages/sir/src/sir/enums.py
index 635835f..c68cdae 100644
--- a/packages/sir/src/sir/enums.py
+++ b/packages/sir/src/sir/enums.py
@@ -52,6 +52,9 @@ class NodeKind(str, Enum):
STYLE_ITEM = "STYLE_ITEM"
STYLE = "STYLE"
LANGUAGE = "LANGUAGE"
+ ACCESS_PROFILE = "ACCESS_PROFILE"
+ ACCESS_GROUP = "ACCESS_GROUP"
+ ACCESS_USER = "ACCESS_USER"
HTTP_SERVICE = "HTTP_SERVICE"
XDTO_PACKAGE = "XDTO_PACKAGE"
EXTENSION = "EXTENSION"
@@ -83,3 +86,5 @@ class EdgeKind(str, Enum):
RUNS = "RUNS"
USES_INTEGRATION = "USES_INTEGRATION"
HANDLES = "HANDLES"
+ ASSIGNS_ROLE = "ASSIGNS_ROLE"
+ MEMBER_OF = "MEMBER_OF"
diff --git a/services/api-server/src/api_server/import_quality_service.py b/services/api-server/src/api_server/import_quality_service.py
index c961e5d..06c2f27 100644
--- a/services/api-server/src/api_server/import_quality_service.py
+++ b/services/api-server/src/api_server/import_quality_service.py
@@ -68,6 +68,30 @@ def import_quality_response(
f"Найдено прав: {summary.rights_count}",
summary.rights_count,
),
+ _quality_check(
+ "access_profiles",
+ "Access profiles",
+ True,
+ f"Найдено профилей групп доступа: {summary.access_profile_count}",
+ summary.access_profile_count,
+ severity="INFO",
+ ),
+ _quality_check(
+ "access_groups",
+ "Access groups",
+ True,
+ f"Найдено групп доступа: {summary.access_group_count}",
+ summary.access_group_count,
+ severity="INFO",
+ ),
+ _quality_check(
+ "access_users",
+ "Access users",
+ True,
+ f"Найдено пользователей ИБ: {summary.access_user_count}",
+ summary.access_user_count,
+ severity="INFO",
+ ),
_quality_check(
"extensions",
"Extensions",
diff --git a/services/api-server/src/api_server/main.py b/services/api-server/src/api_server/main.py
index 1f1eb25..b323106 100644
--- a/services/api-server/src/api_server/main.py
+++ b/services/api-server/src/api_server/main.py
@@ -2889,6 +2889,34 @@ async def get_normalized_project_summary(project_id: str) -> NormalizedProjectSu
return _normalized_project_summary(normalized)
+@app.get("/projects/{project_id}/access")
+async def get_project_access_model(project_id: str) -> dict:
+ normalized = _load_normalized_project(project_id)
+ if normalized is None:
+ raise HTTPException(status_code=404, detail="NormalizedProject not found")
+ return normalized.access.model_dump(mode="json")
+
+
+@app.get("/projects/{project_id}/access/users/{user_name}")
+async def get_project_access_user(project_id: str, user_name: str) -> dict:
+ normalized = _load_normalized_project(project_id)
+ if normalized is None:
+ raise HTTPException(status_code=404, detail="NormalizedProject not found")
+ wanted = user_name.casefold()
+ user = next(
+ (
+ item
+ for item in normalized.access.users
+ if item.name.casefold() == wanted or str(item.qualified_name or "").casefold() == wanted
+ ),
+ None,
+ )
+ if user is None:
+ raise HTTPException(status_code=404, detail="Access user not found")
+ effective_roles = _effective_access_roles(normalized, user)
+ return {"user": user.model_dump(mode="json"), "effective_roles": effective_roles}
+
+
@app.get("/projects/{project_id}/imports/quality", response_model=ImportQualityResponse)
async def get_import_quality(project_id: str) -> ImportQualityResponse:
return _import_quality_response(project_id)
@@ -3985,6 +4013,9 @@ _FLOWCHART_KIND_LABELS = {
NodeKind.STYLE_ITEM: "Элементы стиля",
NodeKind.STYLE: "Стили",
NodeKind.LANGUAGE: "Языки",
+ NodeKind.ACCESS_PROFILE: "Профили групп доступа",
+ NodeKind.ACCESS_GROUP: "Группы доступа",
+ NodeKind.ACCESS_USER: "Пользователи ИБ",
NodeKind.HTTP_SERVICE: "HTTP-сервисы",
NodeKind.INTEGRATION_ENDPOINT: "Интеграции",
NodeKind.SCHEDULED_JOB: "Регламентные задания",
@@ -4008,6 +4039,8 @@ _FLOWCHART_EDGE_LABELS = {
EdgeKind.RUNS: "запускает",
EdgeKind.USES_INTEGRATION: "интеграция",
EdgeKind.HANDLES: "обработчик",
+ EdgeKind.ASSIGNS_ROLE: "назначает роль",
+ EdgeKind.MEMBER_OF: "участник",
}
_FLOWCHART_IMPORTANT_EDGES = {
@@ -4017,6 +4050,8 @@ _FLOWCHART_IMPORTANT_EDGES = {
EdgeKind.WRITES,
EdgeKind.HAS_ROLE,
EdgeKind.GRANTS_ACCESS,
+ EdgeKind.ASSIGNS_ROLE,
+ EdgeKind.MEMBER_OF,
EdgeKind.RUNS,
EdgeKind.USES_INTEGRATION,
EdgeKind.HANDLES,
@@ -4045,6 +4080,9 @@ _FLOWCHART_LOGIC_NODE_KINDS = {
NodeKind.XDTO_PACKAGE,
NodeKind.EXTENSION,
NodeKind.INTEGRATION_ENDPOINT,
+ NodeKind.ACCESS_PROFILE,
+ NodeKind.ACCESS_GROUP,
+ NodeKind.ACCESS_USER,
NodeKind.ROLE,
}
@@ -7210,6 +7248,39 @@ def _load_normalized_project(project_id: str) -> NormalizedProject | None:
return normalized
+def _effective_access_roles(normalized: NormalizedProject, user) -> list[dict]:
+ roles = [item.model_dump(mode="json") for item in user.roles]
+ user_groups = {group.casefold() for group in user.groups}
+ groups = [
+ group
+ for group in normalized.access.groups
+ if group.name.casefold() in user_groups
+ or str(group.qualified_name or "").casefold() in user_groups
+ or user.name in group.users
+ ]
+ profile_names = {
+ value.casefold()
+ for group in groups
+ for value in (group.profile, group.profile_qualified_name)
+ if value
+ }
+ profiles = [
+ profile
+ for profile in normalized.access.profiles
+ if profile.name.casefold() in profile_names or str(profile.qualified_name or "").casefold() in profile_names
+ ]
+ for group in groups:
+ roles.extend(item.model_dump(mode="json") for item in group.roles)
+ for profile in profiles:
+ roles.extend(item.model_dump(mode="json") for item in profile.roles)
+ unique: dict[str, dict] = {}
+ for role in roles:
+ key = str(role.get("role_qualified_name") or role.get("role") or "").casefold()
+ if key:
+ unique.setdefault(key, role)
+ return sorted(unique.values(), key=lambda item: str(item.get("role") or "").casefold())
+
+
def _import_quality_response(project_id: str) -> ImportQualityResponse:
normalized = _load_normalized_project(project_id)
summary = _normalized_project_summary(normalized) if normalized is not None else None
diff --git a/services/api-server/src/api_server/normalized_project_models.py b/services/api-server/src/api_server/normalized_project_models.py
index 22a3652..c94b3d8 100644
--- a/services/api-server/src/api_server/normalized_project_models.py
+++ b/services/api-server/src/api_server/normalized_project_models.py
@@ -20,6 +20,10 @@ class NormalizedProjectSummary(BaseModel):
command_count: int = 0
role_count: int = 0
rights_count: int = 0
+ access_profile_count: int = 0
+ access_group_count: int = 0
+ access_user_count: int = 0
+ access_assignment_count: int = 0
module_count: int = 0
layout_count: int = 0
movement_count: int = 0
diff --git a/services/api-server/src/api_server/normalized_project_service.py b/services/api-server/src/api_server/normalized_project_service.py
index 691820b..e50ace1 100644
--- a/services/api-server/src/api_server/normalized_project_service.py
+++ b/services/api-server/src/api_server/normalized_project_service.py
@@ -30,6 +30,13 @@ def normalized_project_summary(normalized: NormalizedProject) -> NormalizedProje
command_count=sum(len(item.commands) for item in objects),
role_count=sum(1 for item in objects if item.object_kind == "ROLE"),
rights_count=sum(len(item.rights) for item in objects),
+ access_profile_count=len(normalized.access.profiles),
+ access_group_count=len(normalized.access.groups),
+ access_user_count=len(normalized.access.users),
+ access_assignment_count=sum(len(item.roles) for item in normalized.access.profiles)
+ + sum(len(item.roles) for item in normalized.access.groups)
+ + sum(len(item.roles) for item in normalized.access.users)
+ + sum(len(item.users) for item in normalized.access.groups),
module_count=sum(len(item.modules) for item in objects),
layout_count=sum(len(item.layouts) for item in objects),
movement_count=sum(len(item.movements) for item in objects),
diff --git a/services/api-server/tests/test_api.py b/services/api-server/tests/test_api.py
index 8a8719d..892eb43 100644
--- a/services/api-server/tests/test_api.py
+++ b/services/api-server/tests/test_api.py
@@ -1476,6 +1476,13 @@ def test_import_supports_structure_only_indexing(tmp_path: Path):
+
+
+
+
+
+
+
""",
encoding="utf-8",
@@ -1495,6 +1502,9 @@ def test_import_supports_structure_only_indexing(tmp_path: Path):
assert payload["object_count"] >= 2
assert payload["normalized_summary"]["group_count"] >= 5
assert payload["normalized_summary"]["rights_count"] == 1
+ assert payload["normalized_summary"]["access_profile_count"] == 1
+ assert payload["normalized_summary"]["access_group_count"] == 1
+ assert payload["normalized_summary"]["access_user_count"] == 1
setup = client.get(f"/projects/{project_id}/setup")
assert setup.status_code == 200
@@ -1525,6 +1535,14 @@ def test_import_supports_structure_only_indexing(tmp_path: Path):
assert detail.json()["group_name"] == "Роли"
assert detail.json()["object"]["rights"][0]["target"] == "HTTPСервис.ПубличныйAPI"
+ access = client.get(f"/projects/{project_id}/access")
+ assert access.status_code == 200
+ assert access.json()["profiles"][0]["roles"][0]["role_qualified_name"] == "Роль.Менеджер"
+
+ access_user = client.get(f"/projects/{project_id}/access/users/ivanov")
+ assert access_user.status_code == 200
+ assert access_user.json()["effective_roles"][0]["role_qualified_name"] == "Роль.Менеджер"
+
tree = client.get(f"/projects/{project_id}/metadata/tree")
assert tree.status_code == 200
root = tree.json()["root"]
diff --git a/services/runtime-adapter/src/runtime_adapter/main.py b/services/runtime-adapter/src/runtime_adapter/main.py
index b471124..9ed65dc 100644
--- a/services/runtime-adapter/src/runtime_adapter/main.py
+++ b/services/runtime-adapter/src/runtime_adapter/main.py
@@ -245,6 +245,11 @@ def _request_fingerprint(request: RuntimeImportRequest) -> str:
def _mock_project(project_id: str | None) -> NormalizedProject:
from one_c_normalizer import (
+ AccessGroup,
+ AccessModel,
+ AccessProfile,
+ AccessRoleAssignment,
+ AccessUser,
Command,
ConfigurationRoot,
Extension,
@@ -258,6 +263,31 @@ def _mock_project(project_id: str | None) -> NormalizedProject:
return NormalizedProject(
project_id=project_id,
source_path="mock://runtime-adapter",
+ access=AccessModel(
+ profiles=[
+ AccessProfile(
+ name="МенеджерПродаж",
+ qualified_name="ПрофильГруппыДоступа.МенеджерПродаж",
+ roles=[AccessRoleAssignment(role="Менеджер", role_qualified_name="Роль.Менеджер")],
+ )
+ ],
+ groups=[
+ AccessGroup(
+ name="ОтделПродаж",
+ qualified_name="ГруппаДоступа.ОтделПродаж",
+ profile="МенеджерПродаж",
+ users=["demo.user"],
+ )
+ ],
+ users=[
+ AccessUser(
+ name="demo.user",
+ qualified_name="Пользователь.demo.user",
+ full_name="Demo User",
+ groups=["ОтделПродаж"],
+ )
+ ],
+ ),
configuration=ConfigurationRoot(
groups=[
MetadataGroup(