Files
ACORE/apps/nextcloud_connector/access.py
T

313 lines
12 KiB
Python

"""Авторизация доступа к документам аптек и общего раздела /documents/.
Ключевой инвариант безопасности: путь к папке аптеки выводится ТОЛЬКО из
объекта Pharmacy на сервере (никогда из данных клиента), а имя файла проходит
safe_filename() — так исключён обход каталога (path traversal) и переход в чужую
аптеку. Сервисный аккаунт технически видит все папки аптек, поэтому весь скоуп
держится здесь, на стороне портала.
"""
from __future__ import annotations
import posixpath
from apps.nextcloud_connector.services.sync_service import pharmacy_mountpoint
def pharmacy_folder_relpath(pharmacy) -> str:
"""Относительный путь папки аптеки в DAV-дереве сервисного аккаунта."""
return pharmacy_mountpoint(pharmacy)
def can_access_pharmacy_docs(user, pharmacy) -> bool:
"""Кто вправе видеть документы конкретной аптеки (список, скачать, OnlyOffice view).
Политика из PharmacyCardVisibility.show_documents (админка).
"""
from apps.locations.models.pharmacy_visibility import (
PharmacyCardVisibility, user_can_view_pharmacy_section,
)
policy = PharmacyCardVisibility.load()
return user_can_view_pharmacy_section(user, pharmacy, policy.show_documents)
def can_edit_pharmacy_docs(user, pharmacy) -> bool:
"""Кто вправе менять документы аптеки (upload/mkdir/rename/delete, OnlyOffice edit).
Совпадает с правом редактирования карточки (PharmacyCardVisibility.edit_pharmacy).
"""
from apps.locations.models.pharmacy_visibility import user_can_edit_pharmacy
return user_can_edit_pharmacy(user, pharmacy)
# --- Общий браузер документов (страница /documents/) ---
_SECTION_LABELS = {
'company': 'Компания',
'org': 'Структура',
'training': 'Обучение',
'hr': 'Кадровые дела',
'mine': 'Мой отдел',
}
def user_has_mine_tab(user) -> bool:
"""Вкладка «Мой отдел»: есть свой отдел или дополнительные ACL-гранты."""
if not getattr(user, 'is_authenticated', False):
return False
if getattr(user, 'department_id', None):
return True
from apps.nextcloud_connector.doc_acl import _subject_q
from apps.nextcloud_connector.models import DocumentAccessGrant
return DocumentAccessGrant.objects.filter(_subject_q(user), is_active=True).exists()
def additional_share_targets(user) -> list[dict]:
"""Доп. доступные каталоги/файлы по ACL (вне корня собственного отдела).
Каждый элемент — виртуальная запись для списка «Мой отдел».
"""
if not getattr(user, 'is_authenticated', False):
return []
from apps.nextcloud_connector.doc_acl import LEVEL_CODE, _subject_q, normalize_path
from apps.nextcloud_connector.models import DocumentAccessGrant
own_dept = getattr(user, 'department_id', None)
qs = (
DocumentAccessGrant.objects.filter(_subject_q(user), is_active=True)
.select_related('department')
.order_by('section_key', 'department_id', 'path')
)
# Схлопываем дубликаты (section, dept, path) → max level
best: dict[tuple, dict] = {}
for g in qs:
path = normalize_path(g.path)
# Корень своего отдела и так открыт во вкладке — не дублируем
if (
g.section_key == 'org'
and own_dept
and g.department_id
and int(g.department_id) == int(own_dept)
and not path
):
continue
key = (g.section_key, g.department_id, path)
lvl = LEVEL_CODE.get(g.level, 0)
prev = best.get(key)
if prev is None or lvl > prev['level_rank']:
dept_name = g.department.name if g.department_id else ''
section_label = _SECTION_LABELS.get(g.section_key, g.section_key)
if g.section_key == 'org' and dept_name:
label = dept_name if not path else f'{dept_name} / {path}'
elif path:
label = f'{section_label} / {path}'
else:
label = section_label
# Файл: path + імʼя окремо — кліком відкриваємо одразу, не browse
file_name = ''
parent_path = path
is_file = not bool(g.is_folder) and bool(path)
if is_file:
if '/' in path:
parent_path, file_name = path.rsplit('/', 1)
else:
parent_path, file_name = '', path
best[key] = {
'name': label,
'file_name': file_name,
'is_dir': not is_file,
'is_shared_target': True,
'share_section': g.section_key,
'dept_id': g.department_id,
'path': parent_path,
'size': None,
'can_view': True,
'can_edit': lvl >= 2,
'level_rank': lvl,
}
# Стабильный порядок: org с отделами, потом остальные
items = list(best.values())
for it in items:
it.pop('level_rank', None)
items.sort(key=lambda x: (
0 if x['share_section'] == 'org' else 1,
x['name'].lower(),
))
return items
def accessible_sections(user) -> list[dict]:
"""Корневые разделы хранилища, доступные пользователю.
Каждый раздел — {key, label, relpath, writable}. Ключ приходит от клиента,
но раздел резолвится ТОЛЬКО из этого списка (get_section) — произвольный путь
подставить нельзя. Права на запись считаются здесь, а не на клиенте.
"""
if not getattr(user, 'is_authenticated', False):
return []
is_staff = bool(user.is_staff or user.is_superuser)
from apps.nextcloud_connector.services.sync_service import DEPT_ROOT
sections = []
if user_has_mine_tab(user):
sections.append({
'key': 'mine',
'label': 'Мой отдел',
'relpath': '',
'writable': False, # факт записи — по ACL своего отдела
})
sections.extend([
{'key': 'company', 'label': 'Компания', 'relpath': '01_Компания', 'writable': is_staff},
{'key': 'org', 'label': 'Структура', 'relpath': DEPT_ROOT, 'writable': is_staff},
{'key': 'training', 'label': 'Обучение', 'relpath': '05_Обучение', 'writable': is_staff},
])
if user.is_superuser:
sections.append({'key': 'hr', 'label': 'Кадровые дела',
'relpath': '04_Кадровые дела', 'writable': True})
return sections
def get_section(user, key: str) -> dict | None:
for s in accessible_sections(user):
if s['key'] == key:
return dict(s)
return None
def acl_section_key(section: dict) -> str:
"""Ключ для DocumentAccessGrant / resolve_access (mine → org)."""
return section.get('acl_key') or section.get('key') or ''
def department_tree_for_user(user) -> list[dict]:
"""Повне дерево департаментів для сайдбару (метадані з Directory)."""
from core.models import Directory
if not getattr(user, 'is_authenticated', False):
return []
nodes = list(
Directory.objects.filter(directory_type__code='department', is_active=True)
.order_by('_path', 'name')
.only('pk', 'name', 'code', 'parent_id', '_depth', '_path')
)
by_parent: dict[int | None, list] = {}
for n in nodes:
by_parent.setdefault(n.parent_id, []).append(n)
def walk(parent_id):
out = []
for n in by_parent.get(parent_id, []):
out.append({
'id': n.pk,
'name': n.name,
'code': n.code,
'depth': getattr(n, '_depth', 0) or 0,
'children': walk(n.pk),
})
return out
return walk(None)
def _employee_picker_dict(emp) -> dict:
return {
'pk': emp.pk,
'label': emp.get_full_name() or emp.username or '—',
'position': emp.post.name if getattr(emp, 'post_id', None) else '',
'photo': emp.photo.url if getattr(emp, 'photo', None) else '',
}
def people_picker_payload() -> dict:
"""Дані для people-picker: плоский options + дерево з співробітниками."""
from django.contrib.auth import get_user_model
from core.models import Directory
Employee = get_user_model()
depts = list(
Directory.objects.filter(directory_type__code='department', is_active=True)
.select_related('parent')
.order_by('_path', 'name')
)
employees = list(
Employee.objects.filter(is_active=True)
.select_related('post', 'department')
.order_by('last_name', 'first_name')
)
by_dept: dict = {}
for emp in employees:
by_dept.setdefault(emp.department_id, []).append(emp)
nodes = {
d.pk: {
'pk': d.pk,
'name': d.name,
'children': [],
'employees': [_employee_picker_dict(e) for e in by_dept.get(d.pk, [])],
}
for d in depts
}
roots = []
for d in depts:
node = nodes[d.pk]
parent = nodes.get(d.parent_id)
if parent is not None:
parent['children'].append(node)
else:
roots.append(node)
return {
'employee_options': [_employee_picker_dict(e) for e in employees],
'people_picker_tree': {
'tree': roots,
'no_department': [_employee_picker_dict(e) for e in by_dept.get(None, [])],
},
'department_options': [{'pk': d.pk, 'label': d.name} for d in depts],
}
def resolve_department(dept_id) -> object | None:
if not dept_id:
return None
try:
pk = int(dept_id)
except (TypeError, ValueError):
return None
from core.models import Directory
return Directory.objects.filter(
pk=pk, directory_type__code='department', is_active=True,
).first()
def safe_relpath(subpath: str | None) -> str | None:
"""Разбирает подпуть внутри раздела. '' → корень. None, если небезопасно.
Каждый сегмент проходит safe_filename — обход каталога (.., абсолютные пути)
исключён, выйти за пределы раздела нельзя.
"""
if not subpath:
return ''
parts = subpath.strip('/').split('/')
clean = []
for p in parts:
if safe_filename(p) is None:
return None
clean.append(p)
return '/'.join(clean)
def safe_filename(name: str | None) -> str | None:
"""Очищенное имя файла или None, если оно небезопасно.
Отклоняет разделители пути, '.'/'..', скрытые файлы и NUL — чтобы никакой
ввод пользователя не смог выйти за пределы папки аптеки.
"""
if not name:
return None
name = name.strip()
if not name or name in ('.', '..') or name.startswith('.'):
return None
if posixpath.basename(name) != name: # содержал '/' или иной разделитель
return None
if any(c in name for c in ('/', '\\', '\x00')):
return None
return name