diff --git a/acore/settings/base.py b/acore/settings/base.py index 4ffcc57..aeed9fc 100644 --- a/acore/settings/base.py +++ b/acore/settings/base.py @@ -123,6 +123,7 @@ MIDDLEWARE = [ 'django.contrib.auth.middleware.AuthenticationMiddleware', 'apps.employees.middleware.TwoFactorMiddleware', # обязательная 2FA для staff (после Auth) 'core.middleware.DevAccessPreviewMiddleware', + 'core.middleware.PortalSectionAccessMiddleware', # барьер разделов (после preview) 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'core.middleware.UpdateLastSeenMiddleware', diff --git a/apps/feed/tests.py b/apps/feed/tests.py index 889c717..9b2b51e 100644 --- a/apps/feed/tests.py +++ b/apps/feed/tests.py @@ -34,7 +34,11 @@ def make_employee(**overrides): 'ad_sync_enabled': False, } defaults.update(overrides) - return Employee.objects.create(**defaults) + emp = Employee.objects.create(**defaults) + # Любая группа = «провизионированный» сотрудник (видит открытые разделы). + from django.contrib.auth.models import Group + emp.groups.add(Group.objects.get_or_create(name='Сотрудники (тест)')[0]) + return emp class FeedPageTests(TestCase): diff --git a/apps/help/tests.py b/apps/help/tests.py index 8de44e4..8ada9f3 100644 --- a/apps/help/tests.py +++ b/apps/help/tests.py @@ -7,7 +7,10 @@ from core.tests_portal_access import make_employee class HelpPortalTests(TestCase): def setUp(self): + from django.contrib.auth.models import Group self.user = make_employee() + # Любая группа = доступ к открытым разделам (в т.ч. /help/). + self.user.groups.add(Group.objects.get_or_create(name='Сотрудники (тест)')[0]) self.category = HelpCategory.objects.create( slug='test-cat', title='Тест', diff --git a/apps/locations/tests.py b/apps/locations/tests.py index a92433c..8ca6f5e 100644 --- a/apps/locations/tests.py +++ b/apps/locations/tests.py @@ -162,6 +162,9 @@ class DislocationColumnsTests(TestCase): mattermost_sync_enabled=False, ad_sync_enabled=False, ) + # Любая группа = доступ к открытым разделам (в т.ч. /dislocation/). + from django.contrib.auth.models import Group + cls.user.groups.add(Group.objects.get_or_create(name='Сотрудники (тест)')[0]) DislocationColumnPreset.objects.update_or_create( code='standard', defaults={ diff --git a/apps/nextcloud_connector/tests.py b/apps/nextcloud_connector/tests.py index 4d0c12b..0ba2a4e 100644 --- a/apps/nextcloud_connector/tests.py +++ b/apps/nextcloud_connector/tests.py @@ -226,9 +226,12 @@ class SectionsTests(TestCase): class DocumentsBrowserViewTests(TestCase): @classmethod def setUpTestData(cls): + from django.contrib.auth.models import Group cls.user = Employee.objects.create_user( username='browser', mattermost_sync_enabled=False, ad_sync_enabled=False, hire_date=datetime.date(2020, 1, 1)) + # Любая группа = доступ к открытым разделам (в т.ч. /documents/). + cls.user.groups.add(Group.objects.get_or_create(name='Сотрудники (тест)')[0]) def setUp(self): self.client = Client() @@ -266,11 +269,13 @@ class DocumentsBrowserViewTests(TestCase): def test_staff_mkdir_rename_delete(self): from unittest.mock import patch + from django.contrib.auth.models import Group staff = Employee.objects.create_user( username='docs_staff', is_staff=True, mattermost_sync_enabled=False, ad_sync_enabled=False, hire_date=datetime.date(2020, 1, 1), ) + staff.groups.add(Group.objects.get_or_create(name='Сотрудники (тест)')[0]) login_2fa(self.client, staff) dav = patch('apps.nextcloud_connector.views.NextcloudWebDAVService').start() self.addCleanup(patch.stopall) @@ -302,11 +307,13 @@ class DocumentsBrowserViewTests(TestCase): def test_rename_via_hx_prompt_header(self): from unittest.mock import patch + from django.contrib.auth.models import Group staff = Employee.objects.create_user( username='docs_prompt', is_staff=True, mattermost_sync_enabled=False, ad_sync_enabled=False, hire_date=datetime.date(2020, 1, 1), ) + staff.groups.add(Group.objects.get_or_create(name='Сотрудники (тест)')[0]) login_2fa(self.client, staff) with patch('apps.nextcloud_connector.views.NextcloudWebDAVService') as dav: dav.return_value.list_dir.return_value = [] @@ -380,10 +387,12 @@ class OnlyOfficeViewTests(TestCase): @classmethod def setUpTestData(cls): + from django.contrib.auth.models import Group cls.user = Employee.objects.create_user( username='oo_user', mattermost_sync_enabled=False, ad_sync_enabled=False, hire_date=datetime.date(2020, 1, 1), is_staff=True, ) + cls.user.groups.add(Group.objects.get_or_create(name='Сотрудники (тест)')[0]) ConnectorConfig.objects.create( connector_type='onlyoffice', is_enabled=True, diff --git a/apps/tasks/tests.py b/apps/tasks/tests.py index 554e020..f8fa8a9 100644 --- a/apps/tasks/tests.py +++ b/apps/tasks/tests.py @@ -9,11 +9,15 @@ from apps.tasks.models import Task def make_employee(username): # sync-флаги выключены: тестам не нужны сигналы AD/Mattermost - return Employee.objects.create_user( + emp = Employee.objects.create_user( username=username, first_name='T', last_name='User', hire_date=datetime.date(2020, 1, 1), mattermost_sync_enabled=False, ad_sync_enabled=False, ) + # Любая группа = сотрудник видит открытые разделы (например /tasks/). + from django.contrib.auth.models import Group + emp.groups.add(Group.objects.get_or_create(name='Сотрудники (тест)')[0]) + return emp class TaskVisibilityTests(TestCase): diff --git a/apps/visit_manager/tests_calendar.py b/apps/visit_manager/tests_calendar.py index e6326ff..0601063 100644 --- a/apps/visit_manager/tests_calendar.py +++ b/apps/visit_manager/tests_calendar.py @@ -178,6 +178,10 @@ class CalendarEventViewsTests(TestCase): self.staff = make_employee( username='cal.staff', is_staff=True, totp_enabled=True, ) + # Любая группа = доступ к открытым разделам (в т.ч. /calendar/). + grp = Group.objects.get_or_create(name='Сотрудники (тест)')[0] + for u in (self.user, self.stranger, self.staff): + u.groups.add(grp) def test_create_event(self): login_2fa(self.client, self.user) diff --git a/core/middleware.py b/core/middleware.py index 561dc53..bd97bfe 100644 --- a/core/middleware.py +++ b/core/middleware.py @@ -31,6 +31,47 @@ class DevAccessPreviewMiddleware: return self.get_response(request) +class PortalSectionAccessMiddleware: + """Барьер доступа к разделам портала по префиксу URL. + + Ранее «открытые» разделы (новости, задачи, чат и т.п.) были доступны + любому вошедшему. Теперь сотрудник без назначенной группы их не видит и + не может открыть даже прямым URL. Проверка централизована здесь, чтобы + покрыть и вложенные страницы (например /tasks/5/), а не только лендинги. + + Базовые разделы (BASELINE_OPEN_KEYS) и разделы с собственным + @section_required сюда не входят. + """ + + # первый сегмент пути -> ключ раздела портала + PATH_SECTION = { + 'news': 'news', + 'tasks': 'tasks', + 'documents': 'documents', + 'chat': 'chat', + 'calendar': 'calendar', + 'dislocation': 'dislocation', + 'hr-portal': 'hr_portal', + 'help': 'help', + } + # /settings/ не гейтим: это лишь редирект на свой профиль (2FA/пароль). + # Пункт меню «Настройки» скрывается в шаблоне через can_section. + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + user = getattr(request, 'user', None) + if user is not None and getattr(user, 'is_authenticated', False): + segment = request.path.strip('/').split('/', 1)[0] + key = self.PATH_SECTION.get(segment) + if key is not None: + from core.portal_access import deny_section_access, user_can_access + if not user_can_access(user, key): + return deny_section_access(request) + return self.get_response(request) + + class UpdateLastSeenMiddleware: """ Обновляет Employee.last_seen для авторизованных пользователей — основа для diff --git a/core/portal_access.py b/core/portal_access.py index 87fc765..f360575 100644 --- a/core/portal_access.py +++ b/core/portal_access.py @@ -17,6 +17,27 @@ STAFF_ONLY_SECTION_KEYS = frozenset({ 'user_groups', }) | STUB_SECTION_KEYS +# Разделы, доступные любому вошедшему сотруднику даже без назначенных групп +# (непровизионированный сотрудник: посадочная, свой профиль, связь с IT). +# Всё остальное скрыто, пока сотруднику не назначат хотя бы одну группу. +BASELINE_OPEN_KEYS = frozenset({ + 'dashboard', + 'profile', + 'it_support', +}) + + +def _user_has_any_group(user) -> bool: + """Есть ли у пользователя хотя бы одна группа (роль).""" + if getattr(user, 'is_superuser', False): + return True + return user.groups.exists() + + +def _preview_has_group(user) -> bool: + """В access-preview: у выбранного пресета есть хотя бы одна группа.""" + return bool(getattr(user, '_access_preview_groups', None)) + def deny_section_access(request): """Нет доступа к разделу — перенаправление на главную. @@ -67,15 +88,20 @@ def user_can_access(user, key: str) -> bool: return False return True + # Базовые разделы видны всегда (в т.ч. без групп и в access-preview). + if key in BASELINE_OPEN_KEYS: + return True + if preview_perms is not None: if section.perm is None: - return True + return _preview_has_group(user) return section.perm in preview_perms if getattr(user, 'is_superuser', False): return True if section.perm is None: - return True + # Открытый раздел, но без назначенной группы сотрудник его не видит. + return _user_has_any_group(user) return user.has_perm(section.perm) diff --git a/core/tests_portal_access.py b/core/tests_portal_access.py index 5c90598..70d7824 100644 --- a/core/tests_portal_access.py +++ b/core/tests_portal_access.py @@ -43,14 +43,34 @@ def portal_perm(codename: str) -> Permission: class PortalAccessUnitTests(TestCase): - def test_open_section_any_authenticated(self): + def test_baseline_sections_without_group(self): user = make_employee() + # Базовые разделы видны даже без назначенных групп. self.assertTrue(user_can_access(user, 'dashboard')) - self.assertTrue(user_can_access(user, 'dislocation')) + self.assertTrue(user_can_access(user, 'profile')) + self.assertTrue(user_can_access(user, 'it_support')) + # Прочие «открытые» разделы теперь требуют хотя бы одной группы. + self.assertFalse(user_can_access(user, 'dislocation')) + self.assertFalse(user_can_access(user, 'news')) + self.assertFalse(user_can_access(user, 'tasks')) + self.assertFalse(user_can_access(user, 'help')) self.assertFalse(user_can_access(user, 'rro')) - def test_hr_portal_open_for_all_authenticated(self): + def test_open_sections_visible_with_any_group(self): user = make_employee() + user.groups.add(Group.objects.create(name='Пустая роль')) + self.assertTrue(user_can_access(user, 'dislocation')) + self.assertTrue(user_can_access(user, 'news')) + self.assertTrue(user_can_access(user, 'tasks')) + self.assertTrue(user_can_access(user, 'help')) + + def test_hr_portal_requires_group(self): + user = make_employee() + # Без группы HR-портал не виден. + self.assertFalse(user_can_access(user, 'hr_portal')) + self.assertFalse(user_can_manage_hr_portal(user)) + user.groups.add(Group.objects.create(name='Пустая роль')) + # С группой раздел виден, но управление наймом всё ещё недоступно. self.assertTrue(user_can_access(user, 'hr_portal')) self.assertFalse(user_can_manage_hr_portal(user)) @@ -123,6 +143,7 @@ class PortalAccessUnitTests(TestCase): class PortalAccessViewTests(TestCase): def test_pharmacist_open_ok_restricted_forbidden(self): user = make_employee() + user.groups.add(Group.objects.create(name='Пустая роль')) login_2fa(self.client, user) self.assertEqual(self.client.get('/dashboard/', HTTP_HX_REQUEST='true').status_code, 200) self.assertEqual(self.client.get('/dislocation/', HTTP_HX_REQUEST='true').status_code, 200) @@ -136,6 +157,7 @@ class PortalAccessViewTests(TestCase): def test_pharmacist_hr_portal_read_only(self): user = make_employee() + user.groups.add(Group.objects.create(name='Пустая роль')) login_2fa(self.client, user) response = self.client.get('/hr-portal/', HTTP_HX_REQUEST='true') self.assertEqual(response.status_code, 200) @@ -251,7 +273,36 @@ class PortalAccessViewTests(TestCase): staff = make_employee(is_staff=True, user_location_type='office') self.assertTrue(user_can_access(staff, 'purchases')) - self.assertTrue(user_can_access(staff, 'help')) + # help — обычный «открытый» раздел: без группы не виден даже staff. + self.assertFalse(user_can_access(staff, 'help')) + + +class NoGroupSectionAccessTests(TestCase): + """Сотрудник без групп: только базовые разделы, прочее — редирект.""" + + def test_no_group_blocked_from_open_sections(self): + user = make_employee() + login_2fa(self.client, user) + # базовые — доступны + self.assertEqual(self.client.get('/dashboard/', HTTP_HX_REQUEST='true').status_code, 200) + self.assertEqual(self.client.get('/it-support/', HTTP_HX_REQUEST='true').status_code, 200) + # ранее открытые — теперь редирект на дашборд + for url in ('/news/', '/tasks/', '/documents/', '/chat/', + '/calendar/', '/dislocation/', '/hr-portal/', '/help/'): + assert_denied_to_dashboard(self, self.client.get(url, HTTP_HX_REQUEST='true'), url) + # /settings/ не блокируется — редирект на свой профиль (2FA/пароль). + settings_resp = self.client.get('/settings/') + self.assertEqual(settings_resp.status_code, 302) + self.assertIn(f'/employees/{user.pk}/?tab=settings', settings_resp['Location']) + + def test_group_opens_sections(self): + user = make_employee() + user.groups.add(Group.objects.create(name='Пустая роль')) + login_2fa(self.client, user) + for url in ('/news/', '/tasks/', '/dislocation/', '/hr-portal/', '/help/'): + self.assertEqual( + self.client.get(url, HTTP_HX_REQUEST='true').status_code, 200, url, + ) class AccessPreviewTests(TestCase): diff --git a/templates/index.html b/templates/index.html index 0e888a8..01ba28f 100644 --- a/templates/index.html +++ b/templates/index.html @@ -239,6 +239,7 @@ + {% if request.user|can_section:'news' %} @@ -248,7 +249,9 @@ + {% endif %} + {% if request.user|can_section:'hr_portal' %} @@ -259,7 +262,9 @@ + {% endif %} + {% if request.user|can_section:'tasks' %} @@ -269,6 +274,8 @@ + {% endif %} + {% if request.user|can_section:'dislocation' %} @@ -277,6 +284,8 @@ + {% endif %} + {% if request.user|can_section:'documents' %} @@ -286,6 +295,7 @@ + {% endif %} {% if request.user|can_section:'email' %} {% endif %} + {% if request.user|can_section:'chat' %} @@ -307,7 +318,9 @@ + {% endif %} + {% if request.user|can_section:'calendar' %} @@ -316,6 +329,7 @@ + {% endif %} {% if request.user|can_section:'databases' %} {% endif %} + {% if request.user|can_section:'settings' %} @@ -372,11 +387,13 @@ + {% endif %} + {% if request.user|can_section:'structure' or request.user|can_section:'user_groups' or request.user|can_section:'pharmacies' %}