Merge main в feat/45-kpp (после #51 и docs#54)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAKRDNzXtRrwmWz9z6PZu3
This commit is contained in:
Andrey Epifancev 2026-10-07 19:10:20 +04:00
commit a76fc3ed99
18 changed files with 580 additions and 52 deletions

View File

@ -223,6 +223,9 @@ docker compose -f ../docs/dev/docker-compose.yml down -v # полный сб
`src/domain/policy.py`: пользователь головного офиса видит всё, пользователь филиала — ГК своего филиала, их ЮЛ и
ГСК/ГСЗ и монозаёмщиков своего филиала. Объект вне видимости — **404 с тем же телом, что у несуществующего** (не 403).
Филиал в ответах ЮЛ и ГК приходит объектом рядом с `*_id`: `regional_office` (ЮЛ, их история) и `base_regional_office`
(ГК, их история) — `{id, name, is_head_office}` или `null`; неактивный филиал тоже отдаётся (имя нужно для истории).
| Метод и путь | Право | Что делает |
|---|---|---|
| `GET /directories/regional-offices` | `DIRECTORY_VIEW` | филиалы (включая Головной офис) |
@ -873,6 +876,7 @@ email без учёта регистра проверяет только сер
| Переменная | По умолчанию | Назначение |
|---|---|---|
| `OPENBAO__APP__<ИМЯ>` | — | **контур платформы:** туда передаются только секреты OpenBao (`OPENBAO__*`), обычные переменные окружения задать нельзя. Любая настройка верхнего уровня из этой таблицы (`NATS_ENABLED`, `DEBUG`, `AUTH_DEV_ISSUER`, `FORWARDED_ALLOW_IPS`, `CORS_ORIGINS`, …) может прийти как `OPENBAO__APP__<ИМЯ>`, например `OPENBAO__APP__NATS_ENABLED=true`. Значения разбираются как из обычного env (bool, числа, списки). Приоритет: обычный env → `OPENBAO__APP__*` → `.env` → значение по умолчанию. Неизвестное имя — ошибка конфигурации при старте (опечатка не игнорируется); `OPENBAO__APP__OPENBAO__…` не допускается. Читается только из окружения процесса (в `.env` не действует). Реализация — `api/src/core/settings_sources.py` |
| `ENV_FILE` | `.env` | файл окружения (относительно `api/`) |
| `DEBUG` | `false` | режим разработки. `true` необходим dev-эмитенту токенов и `make seed-dev`, включает документацию API (`/docs`, `/redoc`, `/openapi.json`) и CORS `*`; при `false` документации нет (404), CORS — из `CORS_ORIGINS`, пакеты РАИСА подгружаются условно |
| `FORWARDED_ALLOW_IPS` | `127.0.0.1` | доверенные прокси для `X-Forwarded-For`/`X-Forwarded-Proto` (`ProxyHeadersMiddleware`): адреса через запятую или `*`. Адрес клиента (`audit_event.ip_address`) берётся из заголовка только от них, от остальных заголовок игнорируется. На платформе — адреса ingress/балансировщика (OQ-65) |

View File

@ -1,6 +1,15 @@
# Пример локального окружения. Скопируйте в api/.env: cp .env.example .env
# Порты — стандартные для docs/dev/docker-compose.yml.
# Контур платформы: обычные переменные окружения там задать нельзя, передаются только секреты OpenBao (OPENBAO__*).
# Любая настройка верхнего уровня ниже (не вложенные OPENBAO__<ГРУППА>__*) может прийти как OPENBAO__APP__<ИМЯ>, например:
# OPENBAO__APP__NATS_ENABLED=true
# OPENBAO__APP__DEBUG=true
# OPENBAO__APP__AUTH_DEV_ISSUER=true
# OPENBAO__APP__FORWARDED_ALLOW_IPS=10.0.0.1,10.0.0.2
# Приоритет: обычный env -> OPENBAO__APP__* -> .env -> значение по умолчанию. Неизвестное имя — ошибка при старте.
# В .env-файле OPENBAO__APP__* не читается (только процесс окружения).
DEBUG=true
# Dev-запуск слушает только loopback: при DEBUG и AUTH_DEV_ISSUER эндпоинт dev-токенов выдаёт токен любому, кто знает

View File

@ -7,7 +7,7 @@ pytest>=8.3
pytest-asyncio>=0.24
pytest-env>=1.1
pytest-cov>=6.0
PyJWT[crypto]>=2.9
PyJWT[crypto]==2.8.0
pydbml>=1.1
jsonschema>=4.23
types-PyYAML>=6.0

View File

@ -13,7 +13,7 @@ attrs==26.1.0
certifi==2025.11.12
cffi==2.1.0
click==8.4.2
cryptography==50.0.2
cryptography==43.0.3
dnspython==2.8.0
email-validator==2.3.0
et_xmlfile==2.0.0
@ -47,7 +47,7 @@ pydantic-extra-types==2.11.0
pydantic-settings==2.14.2
pydantic_core==2.46.4
Pygments==2.20.0
PyJWT[crypto]==2.15.1
PyJWT[crypto]==2.8.0
PyNaCl==1.5.0
python-dotenv==1.2.2
python-multipart==0.0.32

View File

@ -10,7 +10,6 @@ from fastapi import APIRouter, Depends, Query, status
from src.api.dependencies.auth import require
from src.api.dependencies.directories import CompanyGroupServiceDep
from src.models.directories import CompanyGroup
from src.schemas.common import Page, PageParams
from src.schemas.directories import (
CompanyGroupCreate,
@ -30,12 +29,6 @@ class CompanyGroupListQuery(CompanyGroupFilter, PageParams):
"""Query списка ГК: фильтры и пагинация одной моделью (FastAPI поддерживает одну Query-модель на маршрут)."""
def _out(group: CompanyGroup, legal_entities_count: int) -> CompanyGroupOut:
out = CompanyGroupOut.model_validate(group)
out.legal_entities_count = legal_entities_count
return out
@router.get("", response_model=Page[CompanyGroupOut], dependencies=VIEW, summary="Список ГК")
async def list_company_groups(
service: CompanyGroupServiceDep,
@ -44,9 +37,7 @@ async def list_company_groups(
"""Видимые ГК: поиск `q` по name/crm_id/code, фильтр `is_archived`, пагинация, сортировка по имени."""
page = PageParams(limit=query.limit, offset=query.offset)
rows, total = await service.page(CompanyGroupFilter(q=query.q, is_archived=query.is_archived), page)
return Page[CompanyGroupOut](
items=[_out(group, count) for group, count in rows], total=total, limit=page.limit, offset=page.offset
)
return Page[CompanyGroupOut](items=await service.to_outs(rows), total=total, limit=page.limit, offset=page.offset)
@router.post(
@ -54,13 +45,13 @@ async def list_company_groups(
)
async def create_company_group(data: CompanyGroupCreate, service: CompanyGroupServiceDep) -> CompanyGroupOut:
"""Создаёт ГК с кодом `GK-NNNNNN`; без филиала пользователь филиала закрепляет ГК за своим."""
return _out(await service.create(data), 0)
return await service.to_out(await service.create(data), 0)
@router.get("/{group_id}", response_model=CompanyGroupOut, dependencies=VIEW, summary="Карточка ГК")
async def get_company_group(group_id: uuid.UUID, service: CompanyGroupServiceDep) -> CompanyGroupOut:
group = await service.get(group_id)
return _out(group, await service.legal_entities_count(group.id))
return await service.to_out(group, await service.legal_entities_count(group.id))
@router.patch("/{group_id}", response_model=CompanyGroupOut, dependencies=EDIT, summary="Изменить ГК")
@ -69,19 +60,19 @@ async def update_company_group(
) -> CompanyGroupOut:
"""Меняет `name`/`crm_id`/`base_regional_office_id`; прежние значения уходят в историю."""
group = await service.update(group_id, data)
return _out(group, await service.legal_entities_count(group.id))
return await service.to_out(group, await service.legal_entities_count(group.id))
@router.post("/{group_id}/archive", response_model=CompanyGroupOut, dependencies=EDIT, summary="Архивировать ГК")
async def archive_company_group(group_id: uuid.UUID, service: CompanyGroupServiceDep) -> CompanyGroupOut:
group = await service.set_archived(group_id, True) # noqa: FBT003
return _out(group, await service.legal_entities_count(group.id))
return await service.to_out(group, await service.legal_entities_count(group.id))
@router.post("/{group_id}/unarchive", response_model=CompanyGroupOut, dependencies=EDIT, summary="Вернуть ГК из архива")
async def unarchive_company_group(group_id: uuid.UUID, service: CompanyGroupServiceDep) -> CompanyGroupOut:
group = await service.set_archived(group_id, False) # noqa: FBT003
return _out(group, await service.legal_entities_count(group.id))
return await service.to_out(group, await service.legal_entities_count(group.id))
@router.get(
@ -91,4 +82,4 @@ async def unarchive_company_group(group_id: uuid.UUID, service: CompanyGroupServ
summary="История ГК (новые первыми)",
)
async def company_group_history(group_id: uuid.UUID, service: CompanyGroupServiceDep) -> list[CompanyGroupHistoryOut]:
return [CompanyGroupHistoryOut.model_validate(row) for row in await service.history(group_id)]
return await service.history(group_id)

View File

@ -11,7 +11,6 @@ from fastapi import APIRouter, Depends, Query, status
from src.api.dependencies.auth import require
from src.api.dependencies.directories import AliasServiceDep, LegalEntityServiceDep
from src.models.directories import LegalEntity
from src.schemas.common import Page, PageParams
from src.schemas.directories import (
AliasCreate,
@ -37,13 +36,6 @@ class LegalEntityListQuery(LegalEntityFilter, PageParams):
"""Query списка ЮЛ: фильтры и пагинация одной моделью (FastAPI поддерживает одну Query-модель на маршрут)."""
def _out(entity: LegalEntity, documents_count: int) -> LegalEntityOut:
"""Конвертирует ORM ЮЛ в LegalEntityOut с подсчитанным количеством документов."""
out = LegalEntityOut.model_validate(entity)
out.documents_count = documents_count
return out
@router.get("", response_model=Page[LegalEntityOut], dependencies=VIEW, summary="Список ЮЛ/ИП")
async def list_legal_entities(
service: LegalEntityServiceDep,
@ -56,7 +48,7 @@ async def list_legal_entities(
entity_ids = [entity.id for entity in items]
counts = await service.documents_count_by_entities(entity_ids)
return Page[LegalEntityOut](
items=[_out(entity, counts.get(entity.id, 0)) for entity in items],
items=await service.to_outs(items, counts),
total=total,
limit=page.limit,
offset=page.offset,
@ -69,7 +61,7 @@ async def list_legal_entities(
async def create_legal_entity(data: LegalEntityCreate, service: LegalEntityServiceDep) -> LegalEntityOut:
"""Создаёт ЮЛ/ИП; без ГК — монозаёмщик; ИНН: ЮЛ — 10 цифр, ИП — 12; КПП необязателен, у ИП запрещён."""
entity = await service.create(data)
return _out(entity, 0)
return await service.to_out(entity, 0)
@router.get("/{entity_id}", response_model=LegalEntityDetail, dependencies=VIEW, summary="Карточка ЮЛ/ИП")
@ -86,7 +78,7 @@ async def update_legal_entity(
) -> LegalEntityOut:
"""Меняет `name`/`entity_type`/`crm_id`/`regional_office_id`/`kpp`; прежние значения — в историю (`UPDATE`)."""
entity = await service.update(entity_id, data)
return _out(entity, await service.documents_count(entity_id))
return await service.to_out(entity, await service.documents_count(entity_id))
@router.post("/{entity_id}/move", response_model=LegalEntityOut, dependencies=EDIT, summary="Перевести ЮЛ в другую ГК")
@ -95,7 +87,7 @@ async def move_legal_entity(
) -> LegalEntityOut:
"""Переводит ЮЛ в другую ГК с `effective_date` (≥ сегодня); текущие членства ГСК/ГСЗ закрываются."""
entity = await service.move(entity_id, data)
return _out(entity, await service.documents_count(entity_id))
return await service.to_out(entity, await service.documents_count(entity_id))
@router.post(
@ -107,7 +99,7 @@ async def withdraw_legal_entity(
"""`CLOSE` — закрытие (`close_date = effective_date`), `REMOVE_FROM_GROUP` — вывод; ГК снимается, членства
закрываются."""
entity = await service.withdraw(entity_id, data)
return _out(entity, await service.documents_count(entity_id))
return await service.to_out(entity, await service.documents_count(entity_id))
@router.get(
@ -117,7 +109,7 @@ async def withdraw_legal_entity(
summary="История ЮЛ/ИП (новые первыми)",
)
async def legal_entity_history(entity_id: uuid.UUID, service: LegalEntityServiceDep) -> list[LegalEntityHistoryOut]:
return [LegalEntityHistoryOut.model_validate(row) for row in await service.history(entity_id)]
return await service.history(entity_id)
@router.post(

View File

@ -31,8 +31,6 @@ from loguru import logger
if TYPE_CHECKING:
from src.core.config import Settings
# Алгоритмы РАИСА-проверяющего: асимметричные, явно. ``none`` и HS* не допускаются никогда (атака подмены алгоритма).
JWKS_ALGORITHMS: tuple[str, ...] = ("RS256", "ES256")
DEV_ALGORITHM = "HS256"
# Допуск на расхождение часов с эмитентом токена
JWKS_LEEWAY_SECONDS = 10
@ -152,6 +150,9 @@ class DevHs256Verifier:
)
except jwt.PyJWTError as exc:
raise _to_auth_error(exc) from exc
except (TypeError, ValueError) as exc:
# PyJWT 2.8 не оборачивает нечисловой exp (например, список) в InvalidTokenError: страховка от 500
raise AuthError("недействительный токен") from exc
return _claims_from_payload(payload, self._email_claim)
def issue_token(self, email: str, *, ttl: timedelta = DEV_TOKEN_TTL) -> str:
@ -168,11 +169,19 @@ def _load_static_key(pem: str) -> tuple[Any, tuple[str, ...]]:
key = serialization.load_pem_public_key(pem.replace("\\n", "\n").strip().encode())
except (ValueError, TypeError) as exc:
raise ValueError("AUTH_PUBLIC_KEY не является публичным ключом в формате PEM") from exc
algorithms = _algorithms_for_key(key)
if algorithms is None:
raise ValueError("AUTH_PUBLIC_KEY: поддерживаются ключи RSA и EC P-256 (алгоритмы RS256 и ES256)")
return key, algorithms
def _algorithms_for_key(key: Any) -> tuple[str, ...] | None:
"""Допустимые алгоритмы подписи по типу открытого ключа: RSA → RS256, EC P-256 → ES256; иначе ``None``."""
if isinstance(key, rsa.RSAPublicKey):
return key, ("RS256",)
return ("RS256",)
if isinstance(key, ec.EllipticCurvePublicKey) and key.curve.name == "secp256r1":
return key, ("ES256",)
raise ValueError("AUTH_PUBLIC_KEY: поддерживаются ключи RSA и EC P-256 (алгоритмы RS256 и ES256)")
return ("ES256",)
return None
class JwksVerifier:
@ -221,9 +230,14 @@ class JwksVerifier:
return self._static_key, list(self._static_algorithms)
# PyJWKClient ходит в сеть блокирующе — выносим из цикла событий
signing_key = await asyncio.to_thread(self._client.get_signing_key_from_jwt, token)
# Отдаём сам PyJWK, а не signing_key.key: PyJWT тогда сверяет алгоритм из заголовка токена с типом ключа
# (RSA-ключ с ES256 и наоборот — InvalidAlgorithmError, а не TypeError из недр библиотеки)
return signing_key, list(JWKS_ALGORITHMS)
# Передаём signing_key.key, а не сам PyJWK: PyJWT < 2.10 (в контуре 2.8.0) не принимает PyJWK в decode
# (TypeError «Expecting a PEM-formatted key»). Допустимый алгоритм выводим из типа ключа сами —
# RSA-ключ не принимает ES256-токен и наоборот (InvalidAlgorithmError → 401, а не 500)
key = signing_key.key
algorithms = _algorithms_for_key(key)
if algorithms is None:
raise jwt.InvalidAlgorithmError("тип ключа JWKS не поддерживается")
return key, list(algorithms)
async def verify(self, token: str) -> Claims:
try:

View File

@ -2,8 +2,10 @@ import json
import os
from typing import Annotated, Any
from pydantic import NonNegativeInt, PositiveInt, field_validator
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
from pydantic import NonNegativeInt, PositiveInt, field_validator, model_validator
from pydantic_settings import BaseSettings, NoDecode, PydanticBaseSettingsSource, SettingsConfigDict
from src.core.settings_sources import OpenBaoAppSettingsSource
class DBSettings(BaseSettings):
@ -40,6 +42,16 @@ class OpenBAOSettings(BaseSettings):
NATS: NATSSettings
SMB: SMBSettings = SMBSettings() # все поля необязательны (см. SMBSettings)
@model_validator(mode="before")
@classmethod
def _drop_app(cls, data: Any) -> Any:
"""`OPENBAO__APP__<ИМЯ>` — настройки верхнего уровня `Settings` (читает `OpenBaoAppSettingsSource`), не группа
секретов. Вложенный разбор `__` кладёт их сюда ключом `APP`, а `extra=forbid` отверг бы его; убирается только
этот ключ — опечатка в имени группы (`OPENBAO__DATABSE__...`) по-прежнему ошибка."""
if isinstance(data, dict):
return {key: value for key, value in data.items() if str(key).upper() != "APP"}
return data
class ServerSettings(BaseSettings):
HOST: str = "0.0.0.0"
@ -94,6 +106,24 @@ class Settings(BaseSettings):
MAPPING_POLL_SECONDS: PositiveInt = 5
MAPPING_MAX_ATTEMPTS: PositiveInt = 3
@classmethod
def settings_customise_sources(
cls,
settings_cls: type[BaseSettings],
init_settings: PydanticBaseSettingsSource,
env_settings: PydanticBaseSettingsSource,
dotenv_settings: PydanticBaseSettingsSource,
file_secret_settings: PydanticBaseSettingsSource,
) -> tuple[PydanticBaseSettingsSource, ...]:
"""Обычный env → `OPENBAO__APP__*` → `.env` → значение по умолчанию."""
return (
init_settings,
env_settings,
OpenBaoAppSettingsSource(settings_cls),
dotenv_settings,
file_secret_settings,
)
@field_validator("CORS_ORIGINS", mode="before")
@classmethod
def _split_origins(cls, value: Any) -> Any:

View File

@ -0,0 +1,50 @@
"""Источник настроек `OPENBAO__APP__<ИМЯ>`: несекретные настройки верхнего уровня из OpenBao.
Платформа передаёт в под только переменные `OPENBAO__*`, обычные переменные окружения задать нельзя. Поэтому любая
настройка верхнего уровня `Settings` может прийти как `OPENBAO__APP__<ИМЯ>`
(например `OPENBAO__APP__NATS_ENABLED=true`).
Приоритет: обычный env → `OPENBAO__APP__*` → `.env` → значение по умолчанию.
Файл одинаковый в arm-core, arm-scan-gateway, arm-tabular-parser (менять во всех трёх).
"""
from typing import Any
from pydantic_settings import BaseSettings, EnvSettingsSource
APP_PREFIX = "OPENBAO__APP__"
# Группа секретов не настраивается через APP: секреты — только `OPENBAO__<ГРУППА>__<ИМЯ>`
RESERVED_FIELD = "OPENBAO"
class OpenBaoAppSettingsSource(EnvSettingsSource):
"""Читает `OPENBAO__APP__<ИМЯ>` как обычный env: те же разбор типов, `NoDecode`, вложенность `__`, регистр.
Только процесс окружения (не `.env`): так платформа и передаёт значения. Неизвестное имя — `ValueError`.
"""
def __init__(self, settings_cls: type[BaseSettings]) -> None:
super().__init__(
settings_cls,
env_prefix=APP_PREFIX,
env_nested_delimiter=settings_cls.model_config.get("env_nested_delimiter"),
)
def __call__(self) -> dict[str, Any]:
self._check_names()
return super().__call__()
def _check_names(self) -> None:
known = {name.lower() for name in self.settings_cls.model_fields} - {RESERVED_FIELD.lower()}
delimiter = self.env_nested_delimiter or "__"
prefix = self.env_prefix.lower()
unknown = sorted(
env_name
for env_name in self.env_vars
if env_name.startswith(prefix) and env_name[len(prefix) :].split(delimiter, 1)[0] not in known
)
if unknown:
raise ValueError(
f"Неизвестная настройка в {APP_PREFIX}*: {', '.join(name.upper() for name in unknown)}. "
"Допустимы имена полей верхнего уровня Settings (кроме OPENBAO)."
)

View File

@ -7,6 +7,7 @@ SQL без бизнес-правил; видимость — `src.domain.policy`
import datetime
import uuid
from collections.abc import Iterable
from typing import Any
from sqlalchemy import Select, Sequence, and_, delete, func, or_, select
@ -419,6 +420,17 @@ async def get_regional_office(session: AsyncSession, office_id: uuid.UUID) -> Re
return await session.get(RegionalOffice, office_id)
async def regional_offices_by_ids(
session: AsyncSession, office_ids: Iterable[uuid.UUID | None]
) -> dict[uuid.UUID, RegionalOffice]:
"""Филиалы по набору id одним запросом (включая неактивные); `None` и дубли игнорируются."""
ids = {office_id for office_id in office_ids if office_id is not None}
if not ids:
return {}
rows = (await session.execute(select(RegionalOffice).where(RegionalOffice.id.in_(ids)))).scalars().all()
return {office.id: office for office in rows}
async def list_regional_offices(session: AsyncSession) -> list[RegionalOffice]:
"""Все филиалы по имени."""
return list((await session.execute(select(RegionalOffice).order_by(RegionalOffice.name))).scalars().all())

View File

@ -99,6 +99,9 @@ class CompanyGroupOut(BaseModel):
crm_id: str | None
name: str
base_regional_office_id: uuid.UUID | None
base_regional_office: RegionalOfficeBrief | None = (
None # объект филиала; неактивный тоже отдаётся (имя для истории)
)
is_archived: bool
valid_from: datetime.datetime
legal_entities_count: int = 0
@ -115,6 +118,7 @@ class CompanyGroupHistoryOut(BaseModel):
crm_id: str | None
name: str | None
base_regional_office_id: uuid.UUID | None
base_regional_office: RegionalOfficeBrief | None = None # филиал снимка (в history нет FK — батч по id)
valid_from: datetime.datetime | None
valid_to: datetime.datetime | None
changed_by: uuid.UUID | None
@ -226,6 +230,7 @@ class LegalEntityOut(BaseModel):
name: str
company_group_id: uuid.UUID | None
regional_office_id: uuid.UUID | None
regional_office: RegionalOfficeBrief | None = None # объект филиала; неактивный тоже отдаётся
close_date: datetime.date | None
valid_from: datetime.datetime
created_at: datetime.datetime | None
@ -290,6 +295,7 @@ class LegalEntityHistoryOut(BaseModel):
name: str | None
company_group_id: uuid.UUID | None
regional_office_id: uuid.UUID | None
regional_office: RegionalOfficeBrief | None = None # филиал снимка (в history нет FK — батч по id)
close_date: datetime.date | None
event_type: LeHistoryEvent | None
effective_date: datetime.date | None

View File

@ -6,7 +6,7 @@
import datetime
import uuid
from collections.abc import Mapping
from collections.abc import Iterable, Mapping, Sequence
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
@ -19,7 +19,14 @@ from src.models.directories import COMPANY_GROUP_CODE_SEQ, CompanyGroup, Company
from src.models.enums import AuditAction, AuditSection
from src.repositories import directories as repo
from src.schemas.common import PageParams
from src.schemas.directories import CompanyGroupCreate, CompanyGroupFilter, CompanyGroupUpdate
from src.schemas.directories import (
CompanyGroupCreate,
CompanyGroupFilter,
CompanyGroupHistoryOut,
CompanyGroupOut,
CompanyGroupUpdate,
RegionalOfficeBrief,
)
COMPANY_GROUP_NOT_FOUND = "ГК не найдена" # одинаково для чужой и несуществующей (spec directory-policy)
COMPANY_GROUP_CODE_PREFIX = "GK"
@ -149,14 +156,43 @@ class CompanyGroupService:
raise NotFoundError(COMPANY_GROUP_NOT_FOUND)
return group
async def offices_by_ids(self, office_ids: Iterable[uuid.UUID | None]) -> dict[uuid.UUID, RegionalOfficeBrief]:
"""Филиалы (в т.ч. неактивные) по набору id — один запрос на набор, без N+1."""
offices = await repo.regional_offices_by_ids(self._session, office_ids)
return {office_id: RegionalOfficeBrief.model_validate(office) for office_id, office in offices.items()}
async def to_out(self, group: CompanyGroup, legal_entities_count: int) -> CompanyGroupOut:
"""Ответ по одной ГК с объектом филиала."""
return (await self.to_outs([(group, legal_entities_count)]))[0]
async def to_outs(self, rows: Sequence[tuple[CompanyGroup, int]]) -> list[CompanyGroupOut]:
"""Ответы по набору ГК; филиалы — одним батч-запросом."""
offices = await self.offices_by_ids(group.base_regional_office_id for group, _ in rows)
result: list[CompanyGroupOut] = []
for group, count in rows:
out = CompanyGroupOut.model_validate(group)
out.legal_entities_count = count
out.base_regional_office = (
offices.get(group.base_regional_office_id) if group.base_regional_office_id else None
)
result.append(out)
return result
async def legal_entities_count(self, group_id: uuid.UUID) -> int:
"""Число ЮЛ ГК (для карточки)."""
return await repo.count_legal_entities(self._session, group_id)
async def history(self, group_id: uuid.UUID) -> list[CompanyGroupHistory]:
"""Снимки ГК по убыванию `valid_from`; видимость проверяется как для карточки."""
async def history(self, group_id: uuid.UUID) -> list[CompanyGroupHistoryOut]:
"""Снимки ГК по убыванию `valid_from`; видимость — как для карточки; филиалы снимков — одним батчем."""
group = await self.get(group_id)
return await repo.list_company_group_history(self._session, group.id)
rows = await repo.list_company_group_history(self._session, group.id)
offices = await self.offices_by_ids(row.base_regional_office_id for row in rows)
result: list[CompanyGroupHistoryOut] = []
for row in rows:
out = CompanyGroupHistoryOut.model_validate(row)
out.base_regional_office = offices.get(row.base_regional_office_id) if row.base_regional_office_id else None
result.append(out)
return result
async def page(self, params: CompanyGroupFilter, page: PageParams) -> tuple[list[tuple[CompanyGroup, int]], int]:
"""Страница видимых ГК с числом ЮЛ и общее число по фильтру."""

View File

@ -8,7 +8,7 @@
import datetime
import uuid
from collections.abc import Mapping
from collections.abc import Iterable, Mapping, Sequence
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
@ -28,10 +28,13 @@ from src.schemas.directories import (
LegalEntityCreate,
LegalEntityDetail,
LegalEntityFilter,
LegalEntityHistoryOut,
LegalEntityMove,
LegalEntityOut,
LegalEntityUpdate,
LegalEntityWithdraw,
MembershipOut,
RegionalOfficeBrief,
)
from src.services.company_groups import COMPANY_GROUP_NOT_FOUND
@ -248,6 +251,7 @@ class LegalEntityService:
"""Карточка: ЮЛ, текущие членства ГСК/ГСЗ и алиасы; невидимое и несуществующее — один и тот же 404."""
entity = await self._get_entity(entity_id)
detail = LegalEntityDetail.model_validate(entity)
detail.regional_office = (await self.offices_by_ids([entity.regional_office_id])).get(entity.regional_office_id)
detail.memberships = [
MembershipOut(
related_group_id=group.id,
@ -261,10 +265,39 @@ class LegalEntityService:
detail.aliases = [AliasOut.model_validate(alias) for alias in await repo.list_aliases(self._session, entity.id)]
return detail
async def history(self, entity_id: uuid.UUID) -> list[LegalEntityHistory]:
"""Снимки ЮЛ по убыванию `valid_from` (R-3); видимость — как для карточки."""
async def history(self, entity_id: uuid.UUID) -> list[LegalEntityHistoryOut]:
"""Снимки ЮЛ по убыванию `valid_from` (R-3); видимость — как для карточки; филиалы снимков — одним батчем."""
entity = await self._get_entity(entity_id)
return await repo.list_legal_entity_history(self._session, entity.id)
rows = await repo.list_legal_entity_history(self._session, entity.id)
offices = await self.offices_by_ids(row.regional_office_id for row in rows)
result: list[LegalEntityHistoryOut] = []
for row in rows:
out = LegalEntityHistoryOut.model_validate(row)
out.regional_office = offices.get(row.regional_office_id)
result.append(out)
return result
async def offices_by_ids(
self, office_ids: Iterable[uuid.UUID | None]
) -> dict[uuid.UUID | None, RegionalOfficeBrief]:
"""Филиалы (в т.ч. неактивные) по набору id — один запрос на набор, без N+1."""
offices = await repo.regional_offices_by_ids(self._session, office_ids)
return {office_id: RegionalOfficeBrief.model_validate(office) for office_id, office in offices.items()}
async def to_outs(self, entities: Sequence[LegalEntity], counts: Mapping[uuid.UUID, int]) -> list[LegalEntityOut]:
"""Ответы по набору ЮЛ с числом документов; филиалы — одним батч-запросом."""
offices = await self.offices_by_ids(entity.regional_office_id for entity in entities)
result: list[LegalEntityOut] = []
for entity in entities:
out = LegalEntityOut.model_validate(entity)
out.documents_count = counts.get(entity.id, 0)
out.regional_office = offices.get(entity.regional_office_id)
result.append(out)
return result
async def to_out(self, entity: LegalEntity, documents_count: int) -> LegalEntityOut:
"""Ответ по одному ЮЛ с объектом филиала."""
return (await self.to_outs([entity], {entity.id: documents_count}))[0]
async def page(self, flt: LegalEntityFilter, page: PageParams) -> tuple[list[LegalEntity], int]:
"""Страница видимых ЮЛ и общее число по фильтру."""

View File

@ -265,6 +265,7 @@ async def test_history_is_ordered_by_valid_from_desc(auth_client: AuthClientFact
"crm_id",
"name",
"base_regional_office_id",
"base_regional_office",
"valid_from",
"valid_to",
"changed_by",

View File

@ -390,6 +390,7 @@ async def test_patch_writes_update_snapshot_and_audit(auth_client: AuthClientFac
"company_group_id",
"regional_office_id",
"close_date",
"regional_office",
"event_type",
"effective_date",
"valid_from",

View File

@ -0,0 +1,205 @@
"""Основной РФ объектом в ответах ЮЛ и ГК (arm-core#47): `regional_office` / `base_regional_office`, без N+1."""
import datetime
from collections.abc import Iterator
from typing import Any
import pytest
from sqlalchemy import event
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
from src.common.clock import moscow_date
from src.models.directories import CompanyGroupHistory, LegalEntityHistory
from tests.conftest import AuthClientFactory
from tests.factories import make_company_group, make_legal_entity, make_regional_office
HTTP_OK = 200
HTTP_CREATED = 201
SMALL = 2
ENTITIES = "/directories/legal-entities"
GROUPS = "/directories/company-groups"
KA_GO = "ka_go@armka.local"
def _brief(office: Any) -> dict[str, Any]:
return {"id": str(office.id), "name": office.name, "is_head_office": office.is_head_office}
@pytest.fixture
def sql_log(db_engine: AsyncEngine) -> Iterator[list[str]]:
statements: list[str] = []
def record(_conn: Any, _cursor: Any, statement: str, *_rest: Any) -> None:
statements.append(statement)
event.listen(db_engine.sync_engine, "before_cursor_execute", record)
try:
yield statements
finally:
event.remove(db_engine.sync_engine, "before_cursor_execute", record)
def _office_selects(statements: list[str]) -> int:
return sum(1 for s in statements if s.lstrip().upper().startswith("SELECT") and "FROM dkb.regional_office" in s)
@pytest.mark.integration
async def test_legal_entity_responses_carry_office_object(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
office = await make_regional_office(seeded, name="Филиал Объект")
other = await make_regional_office(seeded, name="Филиал Другой")
group = await make_company_group(seeded, name="ГК Объект", office=office)
entity = await make_legal_entity(seeded, name="ООО Объект", group=group, office=office)
new_group = await make_company_group(seeded, name="ГК Новая цель", office=other)
mono = await make_legal_entity(seeded, name="ООО Без филиала")
client = await auth_client(KA_GO)
card = (await client.get(f"{ENTITIES}/{entity.id}")).json()
assert card["regional_office"] == _brief(office)
assert card["regional_office_id"] == str(office.id)
no_office = (await client.get(f"{ENTITIES}/{mono.id}")).json()
assert no_office["regional_office"] is None and no_office["regional_office_id"] is None
listing = (await client.get(ENTITIES, params={"q": "ООО"})).json()["items"]
by_name = {item["name"]: item for item in listing}
assert by_name["ООО Объект"]["regional_office"] == _brief(office)
assert by_name["ООО Без филиала"]["regional_office"] is None
patched = await client.patch(f"{ENTITIES}/{entity.id}", json={"regional_office_id": str(other.id)})
assert patched.status_code == HTTP_OK, patched.text
assert patched.json()["regional_office"] == _brief(other)
history = (await client.get(f"{ENTITIES}/{entity.id}/history")).json()
assert history[0]["regional_office_id"] == str(office.id)
assert history[0]["regional_office"] == _brief(office) # филиал снимка, не текущий
move = await client.post(
f"{ENTITIES}/{entity.id}/move",
json={
"company_group_id": str(new_group.id),
"effective_date": moscow_date(datetime.datetime.now(datetime.UTC)).isoformat(),
},
)
assert move.status_code == HTTP_OK, move.text
assert move.json()["regional_office"] == _brief(other)
withdraw = await client.post(
f"{ENTITIES}/{entity.id}/withdraw",
json={
"event": "REMOVE_FROM_GROUP",
"effective_date": moscow_date(datetime.datetime.now(datetime.UTC)).isoformat(),
},
)
assert withdraw.status_code == HTTP_OK, withdraw.text
assert withdraw.json()["regional_office"] == _brief(other)
created = await client.post(
ENTITIES,
json={"inn": "7701234567", "name": "ООО Новое", "entity_type": "ЮЛ", "regional_office_id": str(office.id)},
)
assert created.status_code == HTTP_CREATED, created.text
assert created.json()["regional_office"] == _brief(office)
@pytest.mark.integration
async def test_inactive_office_is_still_returned(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
office = await make_regional_office(seeded, name="Филиал Закрытый")
office.is_active = False
await seeded.flush()
group = await make_company_group(seeded, name="ГК Закрытая", office=office)
entity = await make_legal_entity(seeded, name="ООО Закрытое", group=group)
client = await auth_client(KA_GO)
assert (await client.get(f"{ENTITIES}/{entity.id}")).json()["regional_office"] == _brief(office)
assert (await client.get(f"{GROUPS}/{group.id}")).json()["base_regional_office"] == _brief(office)
@pytest.mark.integration
async def test_company_group_responses_carry_office_object(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
office = await make_regional_office(seeded, name="Филиал ГК")
other = await make_regional_office(seeded, name="Филиал ГК 2")
group = await make_company_group(seeded, name="ГК Один", office=office)
bare = await make_company_group(seeded, name="ГК Два")
client = await auth_client(KA_GO)
card = (await client.get(f"{GROUPS}/{group.id}")).json()
assert card["base_regional_office"] == _brief(office) and card["base_regional_office_id"] == str(office.id)
assert (await client.get(f"{GROUPS}/{bare.id}")).json()["base_regional_office"] is None
items = {i["name"]: i for i in (await client.get(GROUPS, params={"q": "ГК "})).json()["items"]}
assert items["ГК Один"]["base_regional_office"] == _brief(office)
assert items["ГК Два"]["base_regional_office"] is None
patched = await client.patch(f"{GROUPS}/{group.id}", json={"base_regional_office_id": str(other.id)})
assert patched.status_code == HTTP_OK, patched.text
assert patched.json()["base_regional_office"] == _brief(other)
for action in ("archive", "unarchive"):
resp = await client.post(f"{GROUPS}/{group.id}/{action}")
assert resp.json()["base_regional_office"] == _brief(other)
created = await client.post(GROUPS, json={"name": "ГК Новая", "base_regional_office_id": str(office.id)})
assert created.status_code == HTTP_CREATED, created.text
assert created.json()["base_regional_office"] == _brief(office)
history = (await client.get(f"{GROUPS}/{group.id}/history")).json()
assert history[0]["base_regional_office_id"] == str(office.id)
assert history[0]["base_regional_office"] == _brief(office)
@pytest.mark.integration
async def test_history_loads_offices_in_one_query(
auth_client: AuthClientFactory, seeded: AsyncSession, sql_log: list[str]
) -> None:
offices = [await make_regional_office(seeded, name=f"Филиал история {n}") for n in range(4)]
group = await make_company_group(seeded, name="ГК История", office=offices[0])
entity = await make_legal_entity(seeded, name="ООО История", group=group, office=offices[0])
now = datetime.datetime.now(datetime.UTC)
for n, office in enumerate(offices):
stamp = now - datetime.timedelta(days=n + 1)
seeded.add(
CompanyGroupHistory(
company_group_id=group.id, name="x", base_regional_office_id=office.id, valid_from=stamp, valid_to=now
)
)
seeded.add(
LegalEntityHistory(
legal_entity_id=entity.id, name="x", regional_office_id=office.id, valid_from=stamp, valid_to=now
)
)
await seeded.flush()
client = await auth_client(KA_GO)
sql_log.clear()
rows = (await client.get(f"{ENTITIES}/{entity.id}/history")).json()
assert {r["regional_office"]["name"] for r in rows} == {o.name for o in offices}
assert _office_selects(sql_log) == 1
sql_log.clear()
rows = (await client.get(f"{GROUPS}/{group.id}/history")).json()
assert {r["base_regional_office"]["name"] for r in rows} == {o.name for o in offices}
assert _office_selects(sql_log) == 1
@pytest.mark.integration
async def test_lists_do_not_grow_queries_with_rows(
auth_client: AuthClientFactory, seeded: AsyncSession, sql_log: list[str]
) -> None:
client = await auth_client(KA_GO)
await client.get(ENTITIES) # прогрев (первый запрос сессии тянет лишнее)
await client.get(GROUPS)
total_before: dict[str, int] = {}
for size in (2, 12):
for n in range(size):
office = await make_regional_office(seeded, name=f"Филиал список {size}-{n}")
group = await make_company_group(seeded, name=f"ГК список {size}-{n}", office=office)
await make_legal_entity(seeded, name=f"ООО список {size}-{n}", group=group, office=office)
for url, key in ((ENTITIES, "regional_office"), (GROUPS, "base_regional_office")):
sql_log.clear()
response = await client.get(url, params={"q": "список", "limit": 200})
assert response.status_code == HTTP_OK, response.text
items = response.json()["items"]
assert all(item[key] is not None for item in items)
total = len(sql_log)
if size == SMALL:
total_before[url] = total
else:
assert len(items) == 14 # noqa: PLR2004 - 2 + 12
assert total == total_before[url]
assert _office_selects(sql_log) == 1

View File

@ -455,6 +455,18 @@ async def test_jwks_client_es256(ec_key: ec.EllipticCurvePrivateKey) -> None:
assert (await verifier.verify(token)).email == EMAIL
async def test_jwks_client_rsa_key_rejects_es256_token(
rsa_key: rsa.RSAPrivateKey, ec_key: ec.EllipticCurvePrivateKey
) -> None:
"""RSA-ключ из JWKS и ES256-токен — AuthError (401), а не TypeError/500; и наоборот."""
verifier = JwksVerifier(jwk_client=_FakeJwkClient(key=rsa_key.public_key()))
with pytest.raises(AuthError, match="недопустимый алгоритм"):
await verifier.verify(jwt.encode(_payload(), _pem_private(ec_key), algorithm="ES256"))
verifier = JwksVerifier(jwk_client=_FakeJwkClient(key=ec_key.public_key()))
with pytest.raises(AuthError, match="недопустимый алгоритм"):
await verifier.verify(jwt.encode(_payload(), _pem_private(rsa_key), algorithm="RS256"))
async def test_jwks_client_key_confusion(rsa_key: rsa.RSAPrivateKey) -> None:
verifier = JwksVerifier(jwk_client=_FakeJwkClient(key=rsa_key.public_key()))
with pytest.raises(AuthError):

View File

@ -0,0 +1,132 @@
"""Несекретные настройки из OpenBao: `OPENBAO__APP__<ИМЯ>` (issue #44; без БД и сети).
Приоритет: обычный env → `OPENBAO__APP__*` → `.env` → значение по умолчанию.
"""
import os
from pathlib import Path
import pytest
from pydantic import ValidationError
from src.core.config import Settings
REQUIRED_ENV = {
"OPENBAO__DATABASE__DATABASE_URL": "postgresql+asyncpg://u:p@localhost:5432/d",
"OPENBAO__MINIO__MINIO_URL": "localhost:9000",
"OPENBAO__MINIO__MINIO_ADMIN_LOGIN": "login",
"OPENBAO__MINIO__MINIO_ADMIN_PASSWORD": "password",
"OPENBAO__MINIO__MINIO_BUCKET": "bucket",
"OPENBAO__NATS__NATS_URL": "nats://localhost:4222",
}
# Плоские настройки, которые тесты ниже задают/проверяют
POLL = 7
PORT = 9000
FLAT_KEYS = {"DEBUG", "NATS_ENABLED", "AUTH_DEV_ISSUER", "FORWARDED_ALLOW_IPS", "CORS_ORIGINS", "OUTBOX_POLL_SECONDS"}
@pytest.fixture
def env(monkeypatch: pytest.MonkeyPatch) -> pytest.MonkeyPatch:
"""Чистое окружение с обязательными OPENBAO__*; остальное из процесса убрано."""
for key in list(os.environ):
if key.startswith(("OPENBAO__", "SERVER__")) or key in FLAT_KEYS:
monkeypatch.delenv(key)
for key, value in REQUIRED_ENV.items():
monkeypatch.setenv(key, value)
return monkeypatch
def _settings(env_file: Path | None = None) -> Settings:
return Settings(_env_file=env_file) # type: ignore[call-arg]
def test_app_value_applied(env: pytest.MonkeyPatch) -> None:
env.setenv("OPENBAO__APP__NATS_ENABLED", "true")
env.setenv("OPENBAO__APP__DEBUG", "true")
s = _settings()
assert s.NATS_ENABLED is True
assert s.DEBUG is True
# Секреты OpenBao по-прежнему читаются во вложенные группы
assert s.OPENBAO.NATS.NATS_URL == "nats://localhost:4222"
def test_default_without_app(env: pytest.MonkeyPatch) -> None:
assert _settings().NATS_ENABLED is False
def test_plain_env_overrides_app(env: pytest.MonkeyPatch) -> None:
env.setenv("OPENBAO__APP__NATS_ENABLED", "true")
env.setenv("NATS_ENABLED", "false")
assert _settings().NATS_ENABLED is False
def test_app_overrides_dotenv(env: pytest.MonkeyPatch, tmp_path: Path) -> None:
dotenv = tmp_path / ".env"
dotenv.write_text("NATS_ENABLED=false\nFORWARDED_ALLOW_IPS=1.1.1.1\n", encoding="utf-8")
env.setenv("OPENBAO__APP__NATS_ENABLED", "true")
s = _settings(dotenv)
assert s.NATS_ENABLED is True # OPENBAO__APP__ сильнее .env
assert s.FORWARDED_ALLOW_IPS == "1.1.1.1" # то, чего нет в OPENBAO__APP__, остаётся из .env
def test_types_parsed_like_plain_env(env: pytest.MonkeyPatch) -> None:
env.setenv("OPENBAO__APP__OUTBOX_POLL_SECONDS", str(POLL))
env.setenv("OPENBAO__APP__AUTH_DEV_ISSUER", "1")
env.setenv("OPENBAO__APP__FORWARDED_ALLOW_IPS", "10.0.0.1,10.0.0.2")
s = _settings()
assert s.OUTBOX_POLL_SECONDS == POLL
assert s.AUTH_DEV_ISSUER is True
assert s.FORWARDED_ALLOW_IPS == "10.0.0.1,10.0.0.2"
@pytest.mark.parametrize(
("raw", "expected"),
[
("https://a.example.ru,https://b.example.ru", ["https://a.example.ru", "https://b.example.ru"]),
('["https://a.example.ru"]', ["https://a.example.ru"]),
("", []),
],
ids=["comma", "json", "empty"],
)
def test_cors_origins_list_parsed_like_plain_env(env: pytest.MonkeyPatch, raw: str, expected: list[str]) -> None:
"""NoDecode-список разбирается валидатором так же, как из обычного env."""
env.setenv("OPENBAO__APP__CORS_ORIGINS", raw)
assert _settings().CORS_ORIGINS == expected
def test_invalid_value_rejected(env: pytest.MonkeyPatch) -> None:
env.setenv("OPENBAO__APP__OUTBOX_POLL_SECONDS", "0")
with pytest.raises(ValidationError, match="OUTBOX_POLL_SECONDS"):
_settings()
def test_unknown_name_is_config_error(env: pytest.MonkeyPatch) -> None:
"""Опечатка не игнорируется молча: ошибка при создании Settings (то есть при старте)."""
env.setenv("OPENBAO__APP__NATS_ENABLD", "true")
with pytest.raises(ValueError, match="NATS_ENABLD"):
_settings()
def test_openbao_group_not_settable_via_app(env: pytest.MonkeyPatch) -> None:
"""Секреты — только `OPENBAO__<ГРУППА>__*`; `OPENBAO__APP__OPENBAO__...` — не имя настройки."""
env.setenv("OPENBAO__APP__OPENBAO__NATS__NATS_URL", "nats://x:4222")
with pytest.raises(ValueError, match="OPENBAO"):
_settings()
def test_app_key_does_not_break_openbao_validation(env: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Ключ APP не попадает в OpenBAOSettings (там extra=forbid) — ни из env, ни из env-файла."""
dotenv = tmp_path / ".env"
dotenv.write_text("OPENBAO__APP__NATS_ENABLED=true\n", encoding="utf-8")
env.setenv("OPENBAO__APP__DEBUG", "true")
s = _settings(dotenv)
assert s.DEBUG is True
assert not hasattr(s.OPENBAO, "APP")
def test_nested_group_still_works(env: pytest.MonkeyPatch) -> None:
env.setenv("OPENBAO__MINIO__MINIO_SECURE", "false")
env.setenv("OPENBAO__APP__SERVER__PORT", str(PORT))
s = _settings()
assert s.OPENBAO.MINIO.MINIO_SECURE is False
assert s.server.PORT == PORT