"""B21/E2: админ-панель пользователей и подразделений. Права: manage_users (создание/блокировка/сброс пароля/роль/юнит), view_audit (журнал аудита). Скоуп: coordinator видит/управляет пользователями своего юнита + подчинённых; РЦУ РЧС — всеми. """ from __future__ import annotations import uuid import secrets from datetime import datetime, timezone from typing import Any, Optional from fastapi import APIRouter, Depends, HTTPException, Query, Request from pydantic import BaseModel from sqlalchemy import text from sqlalchemy.orm import Session from backend.audit import audit_log, visible_unit_ids from backend.logging_setup import get_logger from backend.password_policy import record_password_change, validate_new_password from backend.purge_logs import _retention_days, purge_logs from backend.database import get_db from backend.models import MchsUnit, Role, User, UserRole from backend.routers.auth import ( get_current_user, get_password_hash, require_permission, ) router = APIRouter(prefix='/api/v1/admin/users', tags=['admin-users']) class UserCreate(BaseModel): username: str email: str password: str full_name: Optional[str] = None position: Optional[str] = None unit_id: Optional[str] = None role_name: str # admin | coordinator | operator | observer class UserUpdate(BaseModel): full_name: Optional[str] = None position: Optional[str] = None unit_id: Optional[str] = None role_name: Optional[str] = None is_active: Optional[bool] = None status: Optional[str] = None # active | locked | disabled reset_password: Optional[str] = None # новый пароль (сброс админом) def _unit_scope(db: Session, user: User) -> list[uuid.UUID] | None: """Скоуп в виде UUID-объектов (для сравнения с UUID-колонками).""" scope = visible_unit_ids(db, user) if scope is None: return None return [uuid.UUID(h) if isinstance(h, str) else h for h in scope] def _ensure_can_manage(db: Session, actor: User, target: User) -> None: """coordinator управляет только своим юнитом+подчинёнными; admin — всеми.""" scope = _unit_scope(db, actor) if scope is None: return target_unit = getattr(target, 'unit_id', None) if target_unit: from backend.audit import _uuid_hex # scope — UUID-объекты (из _unit_scope); сравниваем в hex-пространстве scope_hex = {_uuid_hex(s) for s in scope} if scope else set() if _uuid_hex(target_unit) not in scope_hex: raise HTTPException(status_code=403, detail='Пользователь вне вашего скоупа') if target is not None and target.id == actor.id: return # coordinator не может управлять теми, у кого роль admin if actor.role == 'coordinator' and target.role == 'admin': raise HTTPException(status_code=403, detail='Недостаточно прав для этого пользователя') @router.get('') def list_users( status_filter: Optional[str] = Query(default=None, alias='status'), unit_id: Optional[str] = Query(default=None), q: Optional[str] = Query(default=None), current_user: User = Depends(require_permission('manage_users')), db: Session = Depends(get_db), ) -> dict[str, Any]: scope = _unit_scope(db, current_user) query = db.query(User) if scope is not None: query = query.filter(User.unit_id.in_(scope)) if status_filter: query = query.filter(User.status == status_filter) if unit_filter := unit_id: query = query.filter(User.unit_id == unit_filter) if q: like = f'%{q}%' query = query.filter(User.username.ilike(like) | User.full_name.ilike(like)) users = query.order_by(User.username).all() return { 'items': [{ 'id': str(u.id), 'username': u.username, 'email': u.email, 'full_name': u.full_name, 'position': u.position, 'role': u.role, 'status': getattr(u, 'status', 'active'), 'unit_id': str(u.unit_id) if u.unit_id else None, 'last_login': u.last_login.isoformat() if u.last_login else None, } for u in users], 'total': len(users), } @router.get('/units') def list_units( current_user: User = Depends(require_permission('manage_users')), db: Session = Depends(get_db), ) -> dict[str, Any]: scope = _unit_scope(db, current_user) query = db.query(MchsUnit) if scope is not None: query = query.filter(MchsUnit.id.in_(scope)) units = query.order_by(MchsUnit.kind, MchsUnit.name).all() return {'items': [{ 'id': str(u.id), 'name': u.name, 'region': u.region, 'kind': u.kind, 'parent_id': str(u.parent_id) if u.parent_id else None, } for u in units]} @router.post('', status_code=201) def create_user( payload: UserCreate, request: Request, current_user: User = Depends(require_permission('manage_users')), db: Session = Depends(get_db), ) -> dict[str, Any]: if len(payload.password) < 8: raise HTTPException(status_code=400, detail='Минимальная длина пароля: 8 символов') # B23: сложность проверяется и на временный пароль админа (история пуста — # новый пользователь, проверка переиспользования не применима) probe = User(username=payload.username, email=payload.email) try: validate_new_password(db, probe, payload.password) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) if db.query(User).filter(User.username == payload.username).first(): raise HTTPException(status_code=400, detail='Пользователь с таким именем уже существует') if db.query(User).filter(User.email == payload.email).first(): raise HTTPException(status_code=400, detail='Email уже используется') role = db.query(Role).filter(Role.name == payload.role_name).first() if not role: raise HTTPException(status_code=400, detail=f'Роль {payload.role_name} не найдена') unit_id = payload.unit_id if unit_id: unit = db.get(MchsUnit, __import__('uuid').UUID(unit_id)) if not unit: raise HTTPException(status_code=400, detail='Подразделение не найдено') scope = _unit_scope(db, current_user) if scope is not None and uuid.UUID(unit_id) not in scope: raise HTTPException(status_code=403, detail='Подразделение вне вашего скоупа') user = User( username=payload.username, email=payload.email, hashed_password=get_password_hash(payload.password), full_name=payload.full_name, position=payload.position, unit_id=__import__('uuid').UUID(unit_id) if unit_id else None, role=role.name, # legacy-поле для совместимости is_active=True, status='active', must_change_password=True, created_by=current_user.id, ) db.add(user) db.flush() db.add(UserRole(user_id=user.id, role_id=role.id, assigned_by=current_user.id)) db.flush() audit_log(db, current_user, 'user_create', object_type='user', object_id=str(user.id), request=request, details={'username': user.username, 'role': role.name}) return { 'id': str(user.id), 'username': user.username, 'role': role.name, 'unit_id': unit_id, } @router.patch('/{user_id}') def update_user( user_id: str, payload: UserUpdate, request: Request, current_user: User = Depends(require_permission('manage_users')), db: Session = Depends(get_db), ) -> dict[str, Any]: import uuid as uuid_mod target = db.get(User, uuid_mod.UUID(user_id)) if user_id else None if not target: raise HTTPException(status_code=404, detail='Пользователь не найден') _ensure_can_manage(db, current_user, target) changes: dict[str, tuple] = {} if payload.full_name is not None and payload.full_name != target.full_name: changes['full_name'] = (target.full_name, payload.full_name) target.full_name = payload.full_name if payload.position is not None and payload.position != target.position: changes['position'] = (target.position, payload.position) target.position = payload.position if payload.role_name is not None: role = db.query(Role).filter(Role.name == payload.role_name).first() if not role: raise HTTPException(status_code=400, detail=f'Роль {payload.role_name} не найдена') if role.name != target.role: changes['role'] = (target.role, role.name) target.role = role.name (db.query(UserRole) .filter(UserRole.user_id == target.id) .delete()) db.add(UserRole(user_id=target.id, role_id=role.id, assigned_by=current_user.id)) if payload.unit_id is not None: unit = db.get(MchsUnit, uuid_mod.UUID(payload.unit_id)) if not unit: raise HTTPException(status_code=400, detail='Подразделение не найдено') if str(unit.id) != str(getattr(target, 'unit_id', None) or ''): changes['unit'] = (str(getattr(target, 'unit_id', None)), str(unit.id)) target.unit_id = unit.id if payload.status is not None: if payload.status not in ('active', 'locked', 'disabled'): raise HTTPException(status_code=400, detail='Недопустимый статус') if payload.status != getattr(target, 'status', 'active'): changes['status'] = (getattr(target, 'status', 'active'), payload.status) target.status = payload.status if payload.is_active is not None: if payload.is_active != target.is_active: changes['is_active'] = (target.is_active, payload.is_active) target.is_active = payload.is_active if payload.reset_password: # B23: сброс админом тоже под полной политикой probe = User(username=target.username, email=target.email, id=target.id) try: validate_new_password(db, probe, payload.reset_password, current_hash=target.hashed_password) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) changes['password'] = ('***', '***') new_hash = get_password_hash(payload.reset_password) record_password_change(db, target, new_hash, previous_hash=target.hashed_password) target.hashed_password = new_hash target.must_change_password = True if not changes: return {'id': user_id, 'updated': False} target.updated_at = datetime.now(timezone.utc) db.commit() audit_log(db, current_user, 'user_update', object_type='user', object_id=user_id, changes=changes, request=request) return {'id': user_id, 'updated': True, 'changes': list(changes)} @router.get('/audit') def list_audit( limit: int = Query(default=50, ge=1, le=200), offset: int = Query(default=0, ge=0), event_type: Optional[str] = Query(default=None), current_user: User = Depends(require_permission('view_audit')), db: Session = Depends(get_db), ) -> dict[str, Any]: from datetime import timedelta retention = 90 row = db.execute(text( "SELECT value FROM security_settings WHERE key = 'audit_retention_days'")).first() if row: retention = int(row[0]) cutoff = datetime.now(timezone.utc) - timedelta(days=retention) from backend.models import AuditEvent as AE, AuditChange as AC q = db.query(AE).filter(AE.created_at >= cutoff) if event_type: q = q.filter(AE.event_type == event_type) total = q.count() events = q.order_by(AE.created_at.desc()).offset(offset).limit(limit).all() items = [] for ev in events: changes = db.query(AC).filter(AC.event_id == ev.id).all() items.append({ 'id': str(ev.id), 'username': ev.username, 'event_type': ev.event_type, 'object_type': ev.object_type, 'object_id': ev.object_id, 'created_at': ev.created_at.isoformat() if ev.created_at else None, 'changes': [{'field': ch.field_name, 'old': ch.old_value, 'new': ch.new_value} for ch in changes], }) return {'items': items, 'total': q.count(), 'retention_days': retention} @router.post('/maintenance/purge-logs') def run_purge_logs( request: Request, current_user: User = Depends(require_permission('manage_security')), db: Session = Depends(get_db), ) -> dict[str, Any]: """B23: реальное удаление журналов старше audit_retention_days. Ручной запуск из админки; регулярный — cron `python -m backend.purge_logs`. Удаляет audit_events (+changes каскадом) и auth_events. """ result = purge_logs(db) audit_log(db, current_user, 'purge_logs', object_type='maintenance', request=request, details=result) log = get_logger('admin') log.info('purge_logs by %s: %s', getattr(current_user, 'username', '—'), result) return {'purged': result, 'retention_days': _retention_days(db)}