Безопасность довёл до ума — Cursor-генерацию переписал руками. IDOR закрыл, CSRF задушил, refresh rotation теперь как надо. HSTS на staging, ENABLE_DOCS=false, install.env recovery протестил. Backend: - jwt_denylist + auth_epoch: мгновенный revoke access JWT (logout/block/reset) - auth/admin/users: bump epoch, logout с Bearer, forgot_password skip для blocked - install_secrets: путь всегда apps/api/data/secrets/ (bootstrap из корня не ломает Docker) - seed: SEED_DEMO_USERS=false на prod/staging - тесты: jwt revoke, integration, coverage gate 90% Frontend: - logout шлёт Bearer, обработка TOKEN_REVOKED - guards TypeScript fix - E2E: blocked user → 401 сразу после block Infra: - staging/prod compose, TLS nginx, deploy-скрипты - k6 §17.2, backup/health/smoke scripts Docs: - docs/ на русском: project, security, deploy, release (старые md слили) - README короткий + план ТЗ + стандартные логины dev Код готов к плаванию. Капитан может идти писать фронт.
210 lines
6.9 KiB
Python
210 lines
6.9 KiB
Python
from pathlib import Path
|
|
|
|
from app.core.app_settings import apply_settings_to_app, get_settings_payload, write_settings
|
|
from app.core.audit_log import read_audit_events, write_audit_event
|
|
from app.core.config import settings
|
|
from app.core.install_secrets import install_secrets_payload, reveal_install_secret
|
|
from app.core.jwt_denylist import bump_auth_epoch
|
|
from app.core.security import hash_password
|
|
from app.modules.admin.security_diagnostics import build_security_diagnostics_report
|
|
from app.modules.auth.service import revoke_user_refresh_family
|
|
from app.modules.users import repository
|
|
|
|
|
|
def list_admin_users(page: int, limit: int) -> dict:
|
|
users, total = repository.list_users(page, limit)
|
|
return {
|
|
"data": [
|
|
{
|
|
"id": user.id,
|
|
"email": user.email,
|
|
"role": user.role,
|
|
"is_superuser": user.is_superuser,
|
|
"status": user.status,
|
|
}
|
|
for user in users
|
|
],
|
|
"meta": {"total": total, "page": page, "limit": limit},
|
|
}
|
|
|
|
|
|
def _ensure_last_superuser_protection(target, role: str | None, is_superuser: bool | None) -> None:
|
|
super_count = repository.count_superusers()
|
|
role_becomes_admin = target.role if role is None else role
|
|
super_becomes_true = target.is_superuser if is_superuser is None else is_superuser
|
|
is_losing_super = target.role == "admin" and target.is_superuser and (
|
|
role_becomes_admin != "admin" or not super_becomes_true
|
|
)
|
|
if is_losing_super and super_count <= 1:
|
|
raise ValueError("LAST_SUPERUSER_PROTECTED")
|
|
|
|
|
|
def patch_user(
|
|
admin_user, target_user_id: str, role: str | None, status: str | None, is_superuser: bool | None = None
|
|
) -> dict:
|
|
target = repository.get_user_by_id(target_user_id)
|
|
if not target:
|
|
raise ValueError("USER_NOT_FOUND")
|
|
if admin_user.id == target_user_id and role and role != "admin":
|
|
raise ValueError("SELF_DEMOTION_FORBIDDEN")
|
|
if admin_user.id == target_user_id and is_superuser is False:
|
|
raise ValueError("SELF_DEMOTION_FORBIDDEN")
|
|
if admin_user.id == target_user_id and status == "blocked":
|
|
raise ValueError("SELF_BLOCK_FORBIDDEN")
|
|
|
|
admin_count = repository.count_admins()
|
|
if target.role == "admin" and role and role != "admin" and admin_count <= 1:
|
|
raise ValueError("LAST_ADMIN_PROTECTED")
|
|
_ensure_last_superuser_protection(target, role, is_superuser)
|
|
|
|
if role:
|
|
target.role = role
|
|
if is_superuser is not None:
|
|
target.is_superuser = bool(is_superuser)
|
|
if status:
|
|
target.status = status
|
|
if status == "blocked":
|
|
bump_auth_epoch(target.id)
|
|
revoke_user_refresh_family(target.id)
|
|
repository.update_user(target)
|
|
write_audit_event(
|
|
action="admin.user.patch",
|
|
actor_user_id=admin_user.id,
|
|
actor_email=admin_user.email,
|
|
details={"target_user_id": target.id},
|
|
)
|
|
return {
|
|
"id": target.id,
|
|
"email": target.email,
|
|
"role": target.role,
|
|
"is_superuser": target.is_superuser,
|
|
"status": target.status,
|
|
}
|
|
|
|
|
|
def create_admin_user(admin_user, email: str, password: str, role: str, is_superuser: bool, status: str) -> dict:
|
|
if repository.get_user_by_email(email):
|
|
raise ValueError("USER_EXISTS")
|
|
user = repository.create_user(
|
|
email=email,
|
|
password_hash=hash_password(password),
|
|
role=role,
|
|
is_superuser=is_superuser,
|
|
status=status,
|
|
)
|
|
write_audit_event(
|
|
action="admin.user.create",
|
|
actor_user_id=admin_user.id,
|
|
actor_email=admin_user.email,
|
|
details={"target_user_id": user.id},
|
|
)
|
|
return {
|
|
"id": user.id,
|
|
"email": user.email,
|
|
"role": user.role,
|
|
"is_superuser": user.is_superuser,
|
|
"status": user.status,
|
|
}
|
|
|
|
|
|
def reset_user_password(admin_user, target_user_id: str, password: str) -> dict:
|
|
target = repository.get_user_by_id(target_user_id)
|
|
if not target:
|
|
raise ValueError("USER_NOT_FOUND")
|
|
target.password_hash = hash_password(password)
|
|
repository.update_user(target)
|
|
bump_auth_epoch(target.id)
|
|
revoke_user_refresh_family(target.id)
|
|
write_audit_event(
|
|
action="admin.user.reset_password",
|
|
actor_user_id=admin_user.id,
|
|
actor_email=admin_user.email,
|
|
details={"target_user_id": target.id},
|
|
)
|
|
return {"status": "ok"}
|
|
|
|
|
|
def delete_admin_user(admin_user, target_user_id: str) -> dict:
|
|
target = repository.get_user_by_id(target_user_id)
|
|
if not target:
|
|
raise ValueError("USER_NOT_FOUND")
|
|
if admin_user.id == target.id:
|
|
raise ValueError("SELF_DELETE_FORBIDDEN")
|
|
_ensure_last_superuser_protection(target, "user", False)
|
|
if target.role == "admin" and repository.count_admins() <= 1:
|
|
raise ValueError("LAST_ADMIN_PROTECTED")
|
|
repository.delete_user(target.id)
|
|
revoke_user_refresh_family(target.id)
|
|
write_audit_event(
|
|
action="admin.user.delete",
|
|
actor_user_id=admin_user.id,
|
|
actor_email=admin_user.email,
|
|
details={"target_user_id": target.id},
|
|
)
|
|
return {"status": "deleted"}
|
|
|
|
|
|
def get_admin_summary() -> dict:
|
|
return {
|
|
"users_count": repository.count_users(),
|
|
"registrations_day": repository.count_users_registered_today(),
|
|
"admins_count": repository.count_admins(),
|
|
"superusers_count": repository.count_superusers(),
|
|
}
|
|
|
|
|
|
def get_admin_settings() -> dict:
|
|
return get_settings_payload()
|
|
|
|
|
|
def patch_admin_settings(admin_user, values: dict) -> dict:
|
|
merged = write_settings(values)
|
|
apply_settings_to_app(merged)
|
|
write_audit_event(
|
|
action="admin.settings.patch",
|
|
actor_user_id=admin_user.id,
|
|
actor_email=admin_user.email,
|
|
details={"updated_keys": sorted(values.keys())},
|
|
)
|
|
return get_settings_payload()
|
|
|
|
|
|
def get_diagnostics_report() -> dict:
|
|
return build_security_diagnostics_report()
|
|
|
|
|
|
def list_activity_feed(limit: int = 200) -> dict:
|
|
return {"events": read_audit_events(limit=limit)}
|
|
|
|
|
|
def record_ui_activity(admin_user, event: str, meta: dict | None) -> dict:
|
|
write_audit_event(
|
|
action=f"ui.{event}",
|
|
actor_user_id=admin_user.id,
|
|
actor_email=admin_user.email,
|
|
details=meta or {},
|
|
)
|
|
return {"status": "ok"}
|
|
|
|
|
|
def get_server_log_tail(lines: int = 200) -> dict:
|
|
path = Path(settings.server_log_path)
|
|
if not path.exists():
|
|
return {"lines": []}
|
|
return {"lines": path.read_text(encoding="utf-8", errors="ignore").splitlines()[-lines:]}
|
|
|
|
|
|
def get_install_secrets() -> dict:
|
|
return install_secrets_payload()
|
|
|
|
|
|
def reveal_secret(admin_user, key: str) -> dict:
|
|
value = reveal_install_secret(key)
|
|
write_audit_event(
|
|
action="admin.secrets.reveal",
|
|
actor_user_id=admin_user.id,
|
|
actor_email=admin_user.email,
|
|
details={"key": key},
|
|
)
|
|
return {"key": key, "value": value}
|