Corp/backend/employees/ldap/utils/group_utils.py
Игорь Надеин fff16130f3 feat(department-roles): complete LDAP architecture redesign with RoleAssignment model
BREAKING CHANGES:
- Removed OU=Roles from LDAP structure, role groups now created directly in department OU
- Role groups use ROLE_<Name> naming convention (similar to DEP_, POS_)

New features:
- RoleAssignment model: allows assigning roles to ANY employee, not just department members
- New API endpoints: /assign/, /revoke/, /assignments/ on DepartmentRoleViewSet
- Permission system updated: has_dept_perm() checks RoleAssignment first
- Employee profile shows external roles with 'внешняя роль' badge
- Employee list filter includes role-only employees with department_relation field
- Role assignment modal now searches all employees via AJAX

LDAP changes:
- _ensure_role_group(): creates ROLE_<Name> group in department OU
- assign_role(), revoke_role(): manage RoleAssignment + LDAP sync
- rename_role_group(), delete_role_group(): full lifecycle support
- Removed all references to OU=Roles

Migrations:
- 0035: Create RoleAssignment model
- 0036: Data migration from EmployeeDepartment.role

Tests:
- 7 new tests for role assignment (all passing)

Docs:
- 9 detailed reports on architecture, models, API, LDAP, tests
2025-12-30 10:35:34 +03:00

143 lines
5.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
from typing import Iterable, Optional
from django.conf import settings
from ldap3 import BASE, SUBTREE, Connection
from employees.models import Employee
from .text_utils import esc_filter
def _read_user_memberof_dns(conn: Connection, user_dn: str) -> set[str]:
"""Возвращает текущее множество DN групп (memberOf) пользователя."""
conn.search(user_dn, "(objectClass=*)", BASE, attributes=["memberOf"])
if not conn.entries:
return set()
raw = getattr(conn.entries[0], "memberOf", [])
vals = raw.values if hasattr(raw, "values") else raw
return {str(x) for x in (vals or [])}
def _group_add_member(
conn: Connection, group_dn: str, user_dn: str, *, do_write: bool
) -> None:
"""Добавляет пользователя в группу (или пропускает в dry-run)."""
from ldap3 import MODIFY_ADD
if not do_write:
return
ok = conn.modify(group_dn, {"member": [(MODIFY_ADD, [user_dn])]})
if not ok and conn.result.get("description") not in {"typeOrValueExists"}:
raise RuntimeError(f"Add member failed: {group_dn}: {conn.result}")
def _group_remove_member(
conn: Connection, group_dn: str, user_dn: str, *, do_write: bool
) -> None:
"""Удаляет пользователя из группы (или пропускает в dry-run)."""
from ldap3 import MODIFY_DELETE
if not do_write:
return
ok = conn.modify(group_dn, {"member": [(MODIFY_DELETE, [user_dn])]})
if not ok and conn.result.get("description") not in {"noSuchAttribute"}:
raise RuntimeError(f"Remove member failed: {group_dn}: {conn.result}")
def _resolve_group_dns_by_cn(
conn: Connection, cns: set[str], extra_bases: Optional[list[str]] = None
) -> dict[str, str]:
"""Ищет DN групп по их CN в OU=Groups и дополнительных базах (например, Roles отдела)."""
if not cns:
return {}
bases = [getattr(settings, "LDAP_GROUPS_BASE", "")]
if extra_bases:
bases.extend(extra_bases)
found: dict[str, str] = {}
for base in bases:
if not base:
continue
for cn in sorted(cns - set(found.keys())):
conn.search(
base,
f"(&(objectClass=group)(cn={esc_filter(cn)}))",
SUBTREE,
attributes=["distinguishedName", "cn"],
)
if conn.entries:
found[cn] = str(conn.entries[0].entry_dn)
return found
def _desired_group_cns_for_employee(emp: "Employee") -> set[str]:
"""Возвращает целевые CN групп для сотрудника из Django (Position/DeptRole/Direct)."""
cns: set[str] = set()
pos = getattr(emp, "position", None)
if pos is not None and hasattr(pos, "groups"):
cns |= {g.name for g in pos.groups.all()}
user_obj = getattr(emp, "user", None) or emp
if hasattr(user_obj, "groups"):
cns |= {g.name for g in user_obj.groups.all()}
dept_roles = getattr(emp, "department_roles", None)
if dept_roles is not None and hasattr(dept_roles, "all"):
for dr in dept_roles.all():
if hasattr(dr, "groups"):
cns |= {g.name for g in dr.groups.all()}
return cns
def sync_user_groups_by_cns(
conn: Connection,
user_dn: str,
target_cns: Iterable[str],
*,
extra_bases: list[str] | None = None,
do_write: bool = True,
) -> tuple[int, int]:
"""Приводит memberOf пользователя к точному набору CN (добавляет/удаляет).
Args:
conn: соединение LDAP.
user_dn: DN пользователя.
target_cns: итоговый набор CN групп.
extra_bases: доп. базы поиска групп (например, OU=Roles,<dept>).
do_write: False => только расчёт.
Returns:
(added, removed).
Raises:
RuntimeError: если операции LDAP завершаются ошибкой.
"""
desired_dns = set(
_resolve_group_dns_by_cn(
conn, set(target_cns), extra_bases=extra_bases
).values()
)
current_dns = _read_user_memberof_dns(conn, user_dn)
# ограничим удаление только «наших» веток (Groups, OU отделов с ROLE_*),
# как и в батч-логике
groups_base = getattr(settings, "LDAP_GROUPS_BASE", "")
depts_base = getattr(settings, "LDAP_DEPARTMENTS_BASE", "")
to_add = desired_dns - current_dns
to_del = {
dn
for dn in (current_dns - desired_dns)
if (groups_base and dn.endswith(groups_base))
or (depts_base and dn.endswith(depts_base) and "CN=ROLE_" in dn)
}
added = removed = 0
if do_write:
for gdn in sorted(to_add):
_group_add_member(conn, gdn, user_dn, do_write=True)
added += 1
for gdn in sorted(to_del):
_group_remove_member(conn, gdn, user_dn, do_write=True)
removed += 1
else:
added, removed = len(to_add), len(to_del)
return added, removed