refactor(employees): упрощена логика прав для LDAP endpoint

- Использована встроенная система пермишенов DRF вместо ручной проверки
- Добавлен ldap_info в required_perms_by_action с правом view_ldap_info
- Добавлен ldap_info в get_permissions() для применения IsSelfOrStaff
- Удалена избыточная ручная проверка прав внутри метода ldap_info()
- Проверка прав теперь выполняется автоматически через get_object()
- Пользователь может видеть свой LDAP логин (IsSelfOrStaff)
- Админы и пользователи с view_ldap_info могут видеть любые логины
This commit is contained in:
admin i 2026-02-06 14:28:21 +03:00
parent c6cd2ba20d
commit 17c4e506cf
6 changed files with 244 additions and 235 deletions

View File

@ -1,6 +1,5 @@
# flake8: noqa
from __future__ import annotations
# backend/api/v1/employees/views.py
import logging
import traceback
@ -10,6 +9,9 @@ from typing import Any, Dict, List, Optional
from common.emails import send_templated_mail
from django.conf import settings
# backend/api/v1/employees/views.py
logger = logging.getLogger(__name__)
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group, Permission
@ -69,11 +71,11 @@ from employees.utils import (
)
from rest_framework import filters, status, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.exceptions import ValidationError
from ..permissions import (
AdminOrActionOrModelPerms,
@ -1554,6 +1556,7 @@ class EmployeeViewSet(viewsets.ModelViewSet):
required_perms_by_action = {
"add_skill": "employees.manage_employee_skills",
"remove_skill": "employees.manage_employee_skills",
"ldap_info": "employees.view_ldap_info", # Для просмотра чужих LDAP логинов
}
def get_permissions(self):
@ -1568,6 +1571,7 @@ class EmployeeViewSet(viewsets.ModelViewSet):
"destroy",
"add_skill",
"remove_skill",
"ldap_info", # Свой LDAP логин может видеть сам пользователь
}:
return [IsAuthenticated(), (IsSelfOrStaff | AdminOrActionOrModelPerms)()]
@ -2173,8 +2177,8 @@ class EmployeeViewSet(viewsets.ModelViewSet):
# Сброс email_verified при изменении email
if email_changed:
logger.info("[ME PATCH] Step 13: Processing email change...")
from django.utils.crypto import get_random_string
from common.emails import send_templated_mail
from django.utils.crypto import get_random_string
instance.email_verified = False
instance.email_activation_code = get_random_string(6, "0123456789")
@ -2260,68 +2264,70 @@ class EmployeeViewSet(viewsets.ModelViewSet):
GET /api/v1/employees/{id}/ldap-info/
Получает LDAP информацию о сотруднике из Active Directory.
Доступ: только staff или пользователи с правом employees.view_ldap_info
Доступ (через DRF permissions: IsSelfOrStaff | AdminOrActionOrModelPerms):
- Сам пользователь может видеть свой LDAP логин
- Staff/superuser могут видеть любые LDAP логины
- Пользователи с правом employees.view_ldap_info могут видеть любые
Возвращает:
- sAMAccountName: LDAP логин пользователя
- userPrincipalName: полный UPN
- displayName: отображаемое имя из LDAP
Примечания:
- Данные запрашиваются напрямую из LDAP, не из базы Django
- Требуется активная связь с Active Directory
- Проверка прав выполняется автоматически через get_permissions()
"""
from employees.ldap.repositories.ldap_repository import LdapRepository
# Проверка прав доступа
if not (request.user.is_staff or request.user.has_perm('employees.view_ldap_info')):
return Response(
{"detail": "У вас нет прав для просмотра LDAP информации"},
status=status.HTTP_403_FORBIDDEN
)
emp = self.get_object()
emp = self.get_object() # DRF автоматически проверит права через get_permissions()
# Проверяем наличие LDAP связи
try:
ldap_sync = emp.ldap_sync_state
if not ldap_sync or not ldap_sync.ldap_dn:
return Response(
{"detail": "У этого сотрудника нет связи с LDAP"},
status=status.HTTP_404_NOT_FOUND
status=status.HTTP_404_NOT_FOUND,
)
except LdapSyncState.DoesNotExist:
return Response(
{"detail": "У этого сотрудника нет связи с LDAP"},
status=status.HTTP_404_NOT_FOUND
status=status.HTTP_404_NOT_FOUND,
)
# Запрашиваем данные из LDAP
try:
ldap_repo = LdapRepository()
attrs = ldap_repo.read_attrs(
ldap_sync.ldap_dn,
['sAMAccountName', 'userPrincipalName', 'displayName']
["sAMAccountName", "userPrincipalName", "displayName"],
)
if not attrs or not attrs.get('sAMAccountName'):
if not attrs or not attrs.get("sAMAccountName"):
return Response(
{"detail": "Не удалось получить LDAP информацию"},
status=status.HTTP_404_NOT_FOUND
status=status.HTTP_404_NOT_FOUND,
)
return Response({
"sAMAccountName": attrs.get('sAMAccountName'),
"userPrincipalName": attrs.get('userPrincipalName'),
"displayName": attrs.get('displayName'),
}, status=status.HTTP_200_OK)
return Response(
{
"sAMAccountName": attrs.get("sAMAccountName"),
"userPrincipalName": attrs.get("userPrincipalName"),
"displayName": attrs.get("displayName"),
},
status=status.HTTP_200_OK,
)
except Exception as e:
logger.error(f"Error fetching LDAP info for employee {emp.id}: {e}", exc_info=True)
logger.error(
f"Error fetching LDAP info for employee {emp.id}: {e}", exc_info=True
)
return Response(
{"detail": f"Ошибка при запросе LDAP информации: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@action(detail=False, methods=["get"], url_path="export-excel")
@ -2337,11 +2343,12 @@ class EmployeeViewSet(viewsets.ModelViewSet):
Возвращает:
- Excel файл (.xlsx) с данными сотрудников
"""
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill
from django.http import HttpResponse
from datetime import datetime
from django.http import HttpResponse
from openpyxl import Workbook
from openpyxl.styles import Alignment, Font, PatternFill
# Получаем отфильтрованный queryset
queryset = self.filter_queryset(self.get_queryset())
queryset = (
@ -2413,7 +2420,7 @@ class EmployeeViewSet(viewsets.ModelViewSet):
if not phone_field:
return ""
try:
from phonenumbers import format_number, PhoneNumberFormat
from phonenumbers import PhoneNumberFormat, format_number
return format_number(phone_field, PhoneNumberFormat.INTERNATIONAL)
except Exception:
@ -2705,8 +2712,8 @@ class EmployeeViewSet(viewsets.ModelViewSet):
# Сброс email_verified при изменении email
if email_changed:
from django.utils.crypto import get_random_string
from common.emails import send_templated_mail
from django.utils.crypto import get_random_string
emp.email_verified = False
emp.email_activation_code = get_random_string(6, "0123456789")
@ -3557,15 +3564,15 @@ class EmployeeActionViewSet(HistoryActionMixin, viewsets.ModelViewSet):
# Если сотрудник не состоял ни в одном отделе, вручную перемещаем в OU=Dismissed
if not departments_processed:
try:
from employees.ldap.utils.ldap_utils import (
get_base_dn_for_employee,
)
from employees.ldap.infrastructure.connections import (
_ldap,
)
from employees.ldap.repositories.ldap_repository import (
ensure_container_exists,
)
from employees.ldap.utils.ldap_utils import (
get_base_dn_for_employee,
)
from employees.models import LdapSyncState
sync_state = LdapSyncState.objects.filter(

View File

@ -179,7 +179,7 @@ class Employee(AbstractUser):
"Telegram Chat ID",
max_length=100,
blank=True,
help_text="Ваш Telegram Chat ID для получения уведомлений (найдите через @userinfobot)"
help_text="Ваш Telegram Chat ID для получения уведомлений (найдите через @userinfobot)",
)
whatsapp = PhoneNumberField("WhatsApp", blank=True)
wechat = models.CharField("WeChat", max_length=100, blank=True)
@ -516,10 +516,10 @@ class EmployeeDepartment(DateRangeMixin, models.Model):
class RoleAssignment(models.Model):
"""Назначение роли сотруднику (не обязательно члену отдела).
Позволяет назначать роли любым сотрудникам компании,
независимо от их членства в отделе.
Attributes:
employee: Сотрудник, которому назначена роль.
role: Назначенная роль отдела.
@ -527,7 +527,7 @@ class RoleAssignment(models.Model):
assigned_at: Дата/время назначения.
is_active: Активно ли назначение.
"""
employee = models.ForeignKey(
"Employee",
on_delete=models.CASCADE,

View File

@ -8,200 +8,202 @@
*/
class LdapLoginField {
constructor(fieldElement) {
this.field = fieldElement;
this.button = this.field.querySelector('.ldap-login-toggle');
this.textSpan = this.field.querySelector('.ldap-login-text');
this.icon = this.field.querySelector('.ldap-icon');
this.employeeId = this.field.dataset.employeeId;
this.isRevealed = false;
this.ldapLogin = null;
this.init();
constructor(fieldElement) {
this.field = fieldElement;
this.button = this.field.querySelector(".ldap-login-toggle");
this.textSpan = this.field.querySelector(".ldap-login-text");
this.icon = this.field.querySelector(".ldap-icon");
this.employeeId = this.field.dataset.employeeId;
this.isRevealed = false;
this.ldapLogin = null;
this.init();
}
init() {
if (!this.button || !this.textSpan || !this.icon) {
console.error("LDAP Login Field: Required elements not found");
return;
}
init() {
if (!this.button || !this.textSpan || !this.icon) {
console.error('LDAP Login Field: Required elements not found');
return;
}
this.button.addEventListener('click', () => this.toggleReveal());
this.button.addEventListener("click", () => this.toggleReveal());
}
async toggleReveal() {
if (this.isRevealed) {
this.hide();
} else {
await this.reveal();
}
async toggleReveal() {
if (this.isRevealed) {
this.hide();
}
async reveal() {
if (!this.employeeId) {
this.showError("ID сотрудника не указан");
return;
}
// Уже загружено
if (this.ldapLogin) {
this.showLogin(this.ldapLogin);
return;
}
// Показать состояние загрузки
this.field.classList.add("loading");
this.icon.classList.remove("bi-lock-fill");
this.icon.classList.add("bi-hourglass");
try {
const response = await fetch(
`/api/v1/employees/${this.employeeId}/ldap-info/`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
"X-Requested-With": "XMLHttpRequest",
},
credentials: "same-origin",
},
);
if (!response.ok) {
if (response.status === 403) {
throw new Error("У вас нет прав для просмотра LDAP информации");
} else if (response.status === 404) {
throw new Error("LDAP информация не найдена");
} else {
await this.reveal();
throw new Error("Ошибка загрузки LDAP информации");
}
}
const data = await response.json();
if (!data.sAMAccountName) {
throw new Error("LDAP логин не найден");
}
this.ldapLogin = data.sAMAccountName;
this.showLogin(this.ldapLogin);
} catch (error) {
console.error("LDAP Login Field Error:", error);
this.showError(error.message || "Не удалось загрузить LDAP логин");
} finally {
this.field.classList.remove("loading");
}
async reveal() {
if (!this.employeeId) {
this.showError('ID сотрудника не указан');
return;
}
// Уже загружено
if (this.ldapLogin) {
this.showLogin(this.ldapLogin);
return;
}
// Показать состояние загрузки
this.field.classList.add('loading');
this.icon.classList.remove('bi-lock-fill');
this.icon.classList.add('bi-hourglass');
try {
const response = await fetch(`/api/v1/employees/${this.employeeId}/ldap-info/`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
credentials: 'same-origin'
});
if (!response.ok) {
if (response.status === 403) {
throw new Error('У вас нет прав для просмотра LDAP информации');
} else if (response.status === 404) {
throw new Error('LDAP информация не найдена');
} else {
throw new Error('Ошибка загрузки LDAP информации');
}
}
const data = await response.json();
if (!data.sAMAccountName) {
throw new Error('LDAP логин не найден');
}
this.ldapLogin = data.sAMAccountName;
this.showLogin(this.ldapLogin);
} catch (error) {
console.error('LDAP Login Field Error:', error);
this.showError(error.message || 'Не удалось загрузить LDAP логин');
} finally {
this.field.classList.remove('loading');
}
}
showLogin(login) {
this.textSpan.textContent = login;
this.textSpan.classList.remove("blurred");
this.textSpan.classList.add("revealed");
this.icon.classList.remove("bi-lock-fill", "bi-hourglass");
this.icon.classList.add("bi-unlock-fill");
this.field.classList.add("revealed");
this.isRevealed = true;
// Обновить tooltip
this.button.setAttribute("title", "Нажмите, чтобы скрыть LDAP логин");
if (bootstrap && bootstrap.Tooltip) {
const tooltip = bootstrap.Tooltip.getInstance(this.button);
if (tooltip) {
tooltip.dispose();
}
}
showLogin(login) {
this.textSpan.textContent = login;
this.textSpan.classList.remove('blurred');
this.textSpan.classList.add('revealed');
this.icon.classList.remove('bi-lock-fill', 'bi-hourglass');
this.icon.classList.add('bi-unlock-fill');
this.field.classList.add('revealed');
this.isRevealed = true;
// Обновить tooltip
this.button.setAttribute('title', 'Нажмите, чтобы скрыть LDAP логин');
if (bootstrap && bootstrap.Tooltip) {
const tooltip = bootstrap.Tooltip.getInstance(this.button);
if (tooltip) {
tooltip.dispose();
}
}
}
hide() {
this.textSpan.textContent = "••••••••";
this.textSpan.classList.remove("revealed");
this.textSpan.classList.add("blurred");
this.icon.classList.remove("bi-unlock-fill");
this.icon.classList.add("bi-lock-fill");
this.field.classList.remove("revealed");
this.isRevealed = false;
// Обновить tooltip
this.button.setAttribute("title", "Нажмите, чтобы показать LDAP логин");
}
showError(message) {
this.field.classList.add("error");
this.icon.classList.remove("bi-lock-fill", "bi-hourglass");
this.icon.classList.add("bi-exclamation-triangle-fill");
// Показать сообщение об ошибке через tooltip или alert
if (typeof bootstrap !== "undefined" && bootstrap.Toast) {
this.showToast("Ошибка", message, "danger");
} else {
alert(message);
}
hide() {
this.textSpan.textContent = '••••••••';
this.textSpan.classList.remove('revealed');
this.textSpan.classList.add('blurred');
this.icon.classList.remove('bi-unlock-fill');
this.icon.classList.add('bi-lock-fill');
this.field.classList.remove('revealed');
this.isRevealed = false;
// Обновить tooltip
this.button.setAttribute('title', 'Нажмите, чтобы показать LDAP логин');
}
showError(message) {
this.field.classList.add('error');
this.icon.classList.remove('bi-lock-fill', 'bi-hourglass');
this.icon.classList.add('bi-exclamation-triangle-fill');
// Показать сообщение об ошибке через tooltip или alert
if (typeof bootstrap !== 'undefined' && bootstrap.Toast) {
this.showToast('Ошибка', message, 'danger');
} else {
alert(message);
}
// Убрать состояние ошибки через 3 секунды
setTimeout(() => {
this.field.classList.remove('error');
this.icon.classList.remove('bi-exclamation-triangle-fill');
this.icon.classList.add('bi-lock-fill');
}, 3000);
}
showToast(title, message, type = 'info') {
const toastContainer = document.querySelector('.toast-container') ||
this.createToastContainer();
const toastEl = document.createElement('div');
toastEl.className = `toast align-items-center text-bg-${type} border-0`;
toastEl.setAttribute('role', 'alert');
toastEl.setAttribute('aria-live', 'assertive');
toastEl.setAttribute('aria-atomic', 'true');
toastEl.innerHTML = `
// Убрать состояние ошибки через 3 секунды
setTimeout(() => {
this.field.classList.remove("error");
this.icon.classList.remove("bi-exclamation-triangle-fill");
this.icon.classList.add("bi-lock-fill");
}, 3000);
}
showToast(title, message, type = "info") {
const toastContainer =
document.querySelector(".toast-container") || this.createToastContainer();
const toastEl = document.createElement("div");
toastEl.className = `toast align-items-center text-bg-${type} border-0`;
toastEl.setAttribute("role", "alert");
toastEl.setAttribute("aria-live", "assertive");
toastEl.setAttribute("aria-atomic", "true");
toastEl.innerHTML = `
<div class="d-flex">
<div class="toast-body">
<strong>${title}:</strong> ${message}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto"
<button type="button" class="btn-close btn-close-white me-2 m-auto"
data-bs-dismiss="toast" aria-label="Close"></button>
</div>
`;
toastContainer.appendChild(toastEl);
const toast = new bootstrap.Toast(toastEl, { autohide: true, delay: 5000 });
toast.show();
toastEl.addEventListener('hidden.bs.toast', () => {
toastEl.remove();
});
}
createToastContainer() {
const container = document.createElement('div');
container.className = 'toast-container position-fixed top-0 end-0 p-3';
container.style.zIndex = '1056';
document.body.appendChild(container);
return container;
}
toastContainer.appendChild(toastEl);
const toast = new bootstrap.Toast(toastEl, { autohide: true, delay: 5000 });
toast.show();
toastEl.addEventListener("hidden.bs.toast", () => {
toastEl.remove();
});
}
createToastContainer() {
const container = document.createElement("div");
container.className = "toast-container position-fixed top-0 end-0 p-3";
container.style.zIndex = "1056";
document.body.appendChild(container);
return container;
}
}
/**
* Инициализация всех LDAP Login полей на странице
*/
function initLdapLoginFields() {
const fields = document.querySelectorAll('.ldap-login-field');
fields.forEach(field => {
new LdapLoginField(field);
});
const fields = document.querySelectorAll(".ldap-login-field");
fields.forEach((field) => {
new LdapLoginField(field);
});
}
// Инициализация при загрузке DOM
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initLdapLoginFields);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initLdapLoginFields);
} else {
initLdapLoginFields();
initLdapLoginFields();
}
// Экспорт для использования в других модулях

View File

@ -7,7 +7,7 @@
.ldap-login-field {
margin: var(--space-2) 0;
text-align: center;
.ldap-login-toggle {
display: inline-flex;
align-items: center;
@ -21,32 +21,32 @@
font-family: var(--font-mono, 'Courier New', monospace);
cursor: pointer;
transition: all 0.3s ease;
&:hover {
background: var(--color-surface-tertiary);
border-color: var(--color-primary);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
&:active {
transform: translateY(0);
}
.ldap-icon {
font-size: 1rem;
transition: transform 0.3s ease;
}
.ldap-login-text {
letter-spacing: 2px;
transition: filter 0.5s ease, letter-spacing 0.5s ease;
&.blurred {
filter: blur(6px);
user-select: none;
}
&.revealed {
filter: blur(0);
letter-spacing: normal;
@ -55,19 +55,19 @@
}
}
}
// Состояние загрузки
&.loading {
.ldap-login-toggle {
pointer-events: none;
opacity: 0.7;
.ldap-icon {
animation: spin 1s linear infinite;
}
}
}
// Состояние ошибки
&.error {
.ldap-login-toggle {
@ -75,13 +75,13 @@
background: rgba(var(--bs-danger-rgb), 0.1);
}
}
// Раскрытое состояние
&.revealed {
.ldap-login-toggle {
border-color: var(--color-success);
background: rgba(var(--bs-success-rgb), 0.05);
.ldap-icon {
transform: scale(1.1);
}
@ -105,7 +105,7 @@
.ldap-login-toggle {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
&:hover {
background: rgba(255, 255, 255, 0.08);
border-color: var(--color-primary);

View File

@ -45,7 +45,7 @@
const editWrap = document.getElementById('editFormWrap');
if (editWrap) {
const isMobile = () => window.matchMedia('(max-width: 767.98px)').matches;
function getHeaderOffset() {
const sticky = document.querySelector('.ios-header');
return (sticky?.offsetHeight || 0) + 8;
@ -114,7 +114,7 @@
// Отправка формы
actionForm.addEventListener('submit', async (e) => {
e.preventDefault();
// Скрываем предыдущие сообщения
if (actionSuccess) actionSuccess.classList.add('d-none');
if (actionError) actionError.classList.add('d-none');
@ -144,7 +144,7 @@
if (response.ok) {
const result = await response.json();
// Показываем сообщение об успехе
if (actionSuccess) {
const actionNames = {
@ -178,7 +178,7 @@
} else {
const error = await response.json();
let errorMsg = 'Ошибка при создании события.';
if (error.detail) {
errorMsg = error.detail;
} else if (error.error) {
@ -186,7 +186,7 @@
} else if (typeof error === 'object') {
errorMsg = Object.values(error).flat().join('. ');
}
if (actionError) {
actionError.textContent = errorMsg;
actionError.classList.remove('d-none');

View File

@ -66,8 +66,8 @@
{% endif %}
</div>
{# LDAP Login (только для админов/HR) #}
{% if request.user.is_staff or perms.employees.view_ldap_info %}
{# LDAP Login (для себя или админов/HR) #}
{% if is_me or request.user.is_staff or perms.employees.view_ldap_info %}
<div class="ldap-login-field" id="ldapLoginField" data-employee-id="{{ emp.id }}">
<button type="button" class="ldap-login-toggle" data-bs-toggle="tooltip"
data-bs-placement="top" title="Нажмите, чтобы показать LDAP логин">