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
143 lines
5.1 KiB
Python
143 lines
5.1 KiB
Python
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
|