Files
vector/backend/routers/admin_users.py
T
Sadmin 2222b7ad5b B23: политика паролей исполняется + ретенция журналов по-настоящему
- backend/password_policy.py: validate_new_password (длина, сложность
  буквы+цифры, сверка с текущим и историей), record_password_change
  (хранит ПРЕДЫДУШИЙ хэш, обрезка до password_history_count),
  password_expired + enforce_expiry (пометка must_change_password)
- users.password_changed_at + таблица password_history (010_b23_policy)
- change-password и админ-сброс теперь под полной политикой (сложность
  и переиспользование вместо голой длины); get_current_user проверяет
  срок действия каждого пароля при запросе
- backend/purge_logs.py: реальный DELETE просроченных audit_events
  (+changes) и auth_events; POST /admin/users/maintenance/purge-logs
  (manage_security) + CLI python -m backend.purge_logs для cron
- тесты: +19 (258 passed) — валидация/история/срок, purge/идемпотентность/403
2026-09-25 09:56:26 +03:00

325 lines
14 KiB
Python

"""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)}