# Разовый импорт публикаций Инструмент: `scripts/import_publications.py`. Он не импортирует приложение, не читает `.env`, не вызывает `init_db`, не создаёт таблиц и не удаляет записи. Подключение берётся только из libpq environment/service, настроенных владельцем. Production в рамках подготовки не проверялся и не изменялся. Локальные проверки: синтетические сотрудники, повторный запуск, дубликаты, неоднозначности, конфликты DOI/года, сохранение заполненных значений и транзакционный путь на тестовом соединении (read-only / rollback при второй ошибочной вставке / успешный commit). Прогон на настоящем PostgreSQL и проверка grants не выполнялись; первый owner-run dry-run с read-only ролью обязателен. ## Вход и сопоставление Файл владельца `Статьи.csv`: UTF-8 с возможным BOM, разделитель `;`, 1797 записей, SHA256 `1f236be56d99677b1403df29a4c1d3a7e6d10cb90bd355d678201a027921b0ce`. Обязательные колонки: `Название публикации`, `Год`, `DOI`, `Авторы НИУ ВШЭ`. Номер строки в отчёте — номер CSV-записи с заголовком, а не физической строки. Сотрудник сопоставляется по полному ФИО: Unicode NFKC, регистр, пробелы и ё/е. Учитываются все существующие статусы; новые сотрудники не создаются. Ноль совпадений → `employee_not_found`, несколько → `ambiguous_employee`. Инициалы, переводы, перестановки слов и приблизительные совпадения не используются. Публикация сопоставляется **внутри конкретного сотрудника** по нормализованному DOI или полному названию и году. Отсутствующий год БД допускает заполнение при совпадении названия. Если эти способы указывают на разные записи, импорт пропускается (`ambiguous_publication`). Разные заполненные DOI или годы — `doi_conflict` / `year_conflict`. Существующие дубликаты не объединяются и не удаляются. Одна статья у разных сотрудников — предусмотренные моделью авторские связи, а не дубли внутри карточки. Повтор CSV внутри сотрудника не создаёт записи. Каждая поздняя строка дополняет ещё пустые метаданные; первый непустой источник имеет приоритет, в том числе ранее сохранённое значение БД. Повтор без изменений — `csv_duplicate`, дополнение запланированной вставки — `merged_csv_duplicate`. При конфликте непустых метаданных первое значение сохраняется; остальные пустые поля всё равно дополняются. Отчёт содержит `metadata_conflict_first_value_kept`, список `conflict_fields` (только названия полей, без значений) и сводный счётчик `metadata_conflicts`. Конфликт DOI/года идентификации по-прежнему пропускает всю связь. Если строка без DOI была запланирована первой, следующая строка с DOI может дополнить ту же вставку. При повторном запуске уже заполненные записи дают `already_present`. Хеш новой записи детерминирован по сотруднику и DOI либо названию/году. `source_hash` существующих записей не меняется. Пустые ячейки ничего не стирают. Пустые название/авторы, некорректный год (допустимо 1000–9999) или DOI пропускаются как `invalid_fields`. Повреждённая структура CSV прерывает весь запуск до подключения. Несовместимый JSON БД пропускается (`unsupported_raw_data`, `unsupported_csv_metadata`). ## Таблицы, столбцы и права | Объект | Операция / столбцы | |---|---| | `public.employees` | Только SELECT: `id`, `full_name` | | `public.employee_publications` | SELECT: `id`, `employee_id`, `title`, `year`, `doi_url`, `raw_data` | | Новая публикация | INSERT: `employee_id`, `title`, `year`, `doi_url` при наличии, `raw_data`, `source_hash`, `created_at`, `updated_at` | | Существующая публикация | UPDATE: только пустые `title`, `year`, `doi_url`; `raw_data`; служебный `updated_at` | | Sequence идентификатора публикации | USAGE для `nextval`; имя определяется `pg_get_serial_sequence`, обычно `public.employee_publications_id_seq` | Остальные таблицы и sequences не затрагиваются. В `raw_data` сохраняются прежние ключи; добавляется/дополняется `csv_enrichment` только отсутствующими или пустыми значениями. Уже заполненные CSV-метаданные не заменяются. Поля `authors`, `publication_id`, тип материала, язык, аннотация, ссылки HSE и статус не меняются. CSV содержит **только авторов НИУ ВШЭ**, поэтому он не считается полным составом авторов и не заменяет `authors`. Идентификаторы Scopus/WoS, квартиль и остальные непустые исходные ячейки сохраняются в `raw_data.csv_enrichment` без интерпретации. Карточки `employees.current_data` и snapshots не изменяются; отчёт и данные таблицы не означают обновления всех представлений интерфейса. Минимальные собственные grants роли: CONNECT к БД, USAGE схемы public, перечисленные SELECT/INSERT/UPDATE по столбцам, USAGE найденной sequence. Не нужны DELETE, TRUNCATE, CREATE, ALTER, ownership, superuser, доступ к прочим таблицам и sequence UPDATE/setval. Роль новая, без членства в других ролях. Права PUBLIC всё равно наследуются: владелец должен отдельно проверить их; этот набор команд не меняет общие права PUBLIC и не гарантирует изоляцию при уже существующих широких общих grants. В частности TEMP может быть выдан PUBLIC. ## Применение и ограничения Без `--apply` используется PostgreSQL `SERIALIZABLE READ ONLY`, без INSERT, UPDATE и nextval, с rollback. С `--apply` весь план применяется одной транзакцией SERIALIZABLE. Advisory lock предотвращает одновременное применение этим импортером. Таймаут запроса — 60 секунд, ожидания блокировок — 5 секунд. Ошибка записи/конфликт сериализации откатывает все изменения таблиц; автоматических повторов нет. Sequence может получить пропуски при откате — это нормальное поведение PostgreSQL. Изменение входного файла во время запуска прерывает импорт. JSON-отчёт содержит количества insert/update/skip, причины и пары `номер CSV-записи — employee_id`, без ФИО, названий статей и секретов. Счётчики относятся к авторским связям, а не уникальным статьям. Консоль выводит только сводку. Отчёт сначала пишется в `.pending` до commit, после успеха переименовывается. Если остался `.pending`, следующий запуск блокируется: сначала проверьте исход транзакции. Отчёт `.pending` не доказывает commit; ошибка переименования после commit также возможна. Безопасный повторный dry-run позволяет проверить состояние. При необходимости владелец перемещает `.pending` в архив перед повтором. **До применения нужен свежий backup БД.** Во время импорта владелец должен исключить конкурентные записи публикаций на время транзакции, согласовав окно работы; эта подготовка не отключает crawler. Не выполняйте production apply до независимого ревью и развёртывания исправления crawler из этого PR. До исправления `_upsert_employee` запускал синхронизацию публикаций даже для неизменённой карточки. При пустом списке публикаций или отсутствии таблицы синхронизация пропускалась. При непустом списке `_sync_employee_publications` удалял все записи сотрудника, чей `source_hash` отсутствовал среди хешей ответа ВШЭ, включая CSV-вставки. Найденные по `publication_id`/хешу записи полностью получали новый `raw_data`, поэтому теряли CSV-метаданные даже без удаления. Исправление сохраняет записи с dict `raw_data.csv_enrichment` при очистке устаревших публикаций. При обновлении сохраняется этот ключ и заполненные title/year/doi_url, если ВШЭ вернул пустые значения. Непустые значения ВШЭ продолжают обновлять канонические поля, а исходные CSV-значения остаются в метаданных. Если статья впервые появилась у ВШЭ, crawler сопоставляет её с импортированной записью по DOI или названию/году до новой вставки, сохраняет id и добавляет HSE publication_id. Конфликт/неоднозначность откатывает весь savepoint синхронизации публикаций и регистрирует CrawlError; карточка сотрудника продолжает обрабатываться. Удаление обычных устаревших HSE-записей остаётся прежним. Здесь не приведены команды рестарта или отключения сервисов. Уже установленный HSE publication_id CSV-записи нельзя заменить другим HSE ID, даже когда DOI/название/год совпадают. Это проверяется перед изменением полей при любом пути сопоставления (ID, source_hash, CSV-поля). Поэтому две HSE-статьи не могут по очереди перезаписать одну CSV-запись: весь savepoint откатывается независимо от порядка статей. Отсутствующий ID во входе не очищает существующую CSV-привязку. Отчёт обхода (`errors` в деталях запуска) содержит CrawlError с идентификатором записи БД и конфликтующими HSE ID, без ФИО и названий статей. ## Команды владельцу (не выполнялись) Ниже — подготовка разового доступа после ревью. Выполняет владелец на сервере. Рабочая директория — проверенная копия этого PR; файлы программы и CSV должны быть защищены от записи временным SSH-пользователем. Пароли/ключи не включать в репозиторий или чат. Имена контейнеров соответствуют текущему Compose-проекту; владелец проверяет их локально перед выполнением. Сначала резервная копия в приватный каталог владельца: ```sh install -d -m 700 /var/backups/miem-publication-import docker exec miem_workers-postgres-1 pg_dump -U miem -d miem_workers -Fc -f /tmp/publications-before.dump docker cp miem_workers-postgres-1:/tmp/publications-before.dump /var/backups/miem-publication-import/before.dump chmod 600 /var/backups/miem-publication-import/before.dump ``` При повторной операции используйте новый путь backup; не перезаписывайте нужную предыдущую копию. Проверьте exit codes и архив `pg_restore --list` до выдачи write. Откройте psql владельцем БД: ```sh docker exec -it miem_workers-postgres-1 psql -U miem -d miem_workers ``` Выполните SQL (без пароля в SQL/истории): ```sql \set ON_ERROR_STOP on SELECT format('CREATE ROLE miem_publication_import LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS CONNECTION LIMIT 1 VALID UNTIL %L', CURRENT_TIMESTAMP + interval '24 hours') \gexec \password miem_publication_import GRANT CONNECT ON DATABASE miem_workers TO miem_publication_import; GRANT USAGE ON SCHEMA public TO miem_publication_import; GRANT SELECT (id, full_name) ON public.employees TO miem_publication_import; GRANT SELECT (id, employee_id, title, year, doi_url, raw_data) ON public.employee_publications TO miem_publication_import; -- Сначала выдаются только read-права для dry-run. SELECT pg_get_serial_sequence('public.employee_publications', 'id'); SELECT has_schema_privilege('miem_publication_import', 'public', 'CREATE') AS unexpected_create, has_table_privilege('miem_publication_import', 'public.employees', 'INSERT') AS unexpected_employee_insert, has_table_privilege('miem_publication_import', 'public.employee_publications', 'DELETE') AS unexpected_delete; ``` Три проверки должны вернуть false. Не выдавайте write-права при неожиданном доступе. Если sequence не найдена, выясните фактический механизм id перед импортом. Срок роли ограничивает парольную авторизацию, но не заменяет отзыв доступа и завершение открытых сессий. После принятого dry-run, непосредственно перед разрешённым apply: ```sql GRANT INSERT (employee_id, title, year, doi_url, raw_data, source_hash, created_at, updated_at) ON public.employee_publications TO miem_publication_import; GRANT UPDATE (title, year, doi_url, raw_data, updated_at) ON public.employee_publications TO miem_publication_import; SELECT format('GRANT USAGE ON SEQUENCE %s TO miem_publication_import', pg_get_serial_sequence('public.employee_publications', 'id')) \gexec ``` Подготовьте внутри API-контейнера защищённые файлы libpq `pg_service.conf` и `pgpass` из окружения/секрет-хранилища владельца: service `miem_publication_import`, БД `miem_workers`, пользователь `miem_publication_import`. Используйте внутренний PostgreSQL endpoint Compose, порт 5432; не публикуйте порт БД. Поле password хранится только в `pgpass`, доступном UID 65534 с mode 600. Не копируйте `.env` приложения. Этот шаг зависит от секрет-хранилища владельца; универсальной команды получения секрета нет. Установите файлы (команды root; каталог контейнера не должен быть volume с непроверенными файлами). `Статьи.csv` расположен рядом с каталогом репозитория: ```sh docker exec miem_workers-api-1 mkdir -p /opt/miem-import /tmp/miem-import docker cp scripts/import_publications.py miem_workers-api-1:/opt/miem-import/import_publications.py docker cp ../Статьи.csv miem_workers-api-1:/opt/miem-import/articles.csv docker exec miem_workers-api-1 chown -R root:root /opt/miem-import docker exec miem_workers-api-1 chmod 755 /opt/miem-import docker exec miem_workers-api-1 chmod 644 /opt/miem-import/import_publications.py /opt/miem-import/articles.csv docker exec miem_workers-api-1 chown 65534:65534 /opt/miem-import/pg_service.conf /opt/miem-import/pgpass /tmp/miem-import docker exec miem_workers-api-1 chmod 600 /opt/miem-import/pg_service.conf /opt/miem-import/pgpass docker exec miem_workers-api-1 chmod 700 /tmp/miem-import docker exec miem_workers-api-1 sha256sum /opt/miem-import/articles.csv install -m 755 -o root -g root scripts/import-publications-run /usr/local/sbin/miem-publication-import install -m 755 -o root -g root scripts/import-publications-ssh /usr/local/sbin/miem-publication-import-ssh useradd --create-home --shell /bin/sh miem-pub-import install -d -m 700 -o miem-pub-import -g miem-pub-import /home/miem-pub-import/.ssh ``` Проверьте SHA256 CSV по указанному выше значению и script по проверенной копии PR. Секретные файлы должны существовать перед командами chown/chmod. Root-владелец каталога не позволяет UID 65534 заменить программу/CSV; этот UID может писать только отчёт и свои файлы конфигурации. Не предоставляйте SSH-пользователю группу docker или общий sudo. Каталоги и executables выше должны быть root-owned и недоступны для записи этому пользователю. Убедитесь, что абсолютный путь Docker на сервере — `/usr/bin/docker`. Создайте `/etc/sudoers.d/miem-publication-import` с mode 440: ```sudoers miem-pub-import ALL=(root) NOPASSWD: /usr/local/sbin/miem-publication-import dry-run, /usr/local/sbin/miem-publication-import apply ``` ```sh visudo -cf /etc/sudoers.d/miem-publication-import ``` В `/home/miem-pub-import/.ssh/authorized_keys` добавьте **только публичный** ключ владельца с ограничениями (замените `` самим публичным ключом): ```text restrict,command="/usr/local/sbin/miem-publication-import-ssh" ``` ```sh chown root:root /home/miem-pub-import/.ssh/authorized_keys chmod 644 /home/miem-pub-import/.ssh/authorized_keys chown root:root /home/miem-pub-import /home/miem-pub-import/.ssh chmod 755 /home/miem-pub-import chmod 755 /home/miem-pub-import/.ssh ``` Root-владение home/.ssh предотвращает замену ограниченного authorized_keys. Убедитесь, что SSH не разрешает этому пользователю другие ключи, пароль, keyboard-interactive, пользовательские environment/rc или общий shell. При необходимости задайте в sshd `Match User miem-pub-import` с `AuthenticationMethods publickey`, `PasswordAuthentication no`, `KbdInteractiveAuthentication no`, `DisableForwarding yes`, `PermitTTY no`, `PermitUserRC no` и указанным ForceCommand. Проверка/reload SSH зависит от конфигурации сервера; перед reload сохраните действующую сессию владельца. С клиентской машины параметры хоста/ключа берутся из окружения владельца: ```sh ssh -i "$IMPORT_SSH_KEY" -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes "miem-pub-import@$PROD_HOST" 'import-publications dry-run' ``` После проверки сводки, выдачи write-grants и отдельного решения владельца: ```sh ssh -i "$IMPORT_SSH_KEY" -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes "miem-pub-import@$PROD_HOST" 'import-publications apply' ssh -i "$IMPORT_SSH_KEY" -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes "miem-pub-import@$PROD_HOST" 'import-publications dry-run' ``` Повторный dry-run должен показать ноль insert/update. При concurrent changes сводки могут различаться; без production-проверки фактические количества неизвестны. Владелец забирает отчёты без предоставления SCP/general shell временному ключу: ```sh docker cp miem_workers-api-1:/tmp/miem-import/apply.json ./publication-import-apply.json docker cp miem_workers-api-1:/tmp/miem-import/dry-run.json ./publication-import-dry-run.json ``` ## Отзыв доступа Владелец прекращает новые SSH-входы: перемещает временный authorized_keys в свой закрытый архив и удаляет только этот sudoers-файл. Не удаляйте данные БД или volumes. ```sh mv /home/miem-pub-import/.ssh/authorized_keys /var/backups/miem-publication-import/authorized_keys.disabled rm /etc/sudoers.d/miem-publication-import ``` В psql владельцем: ```sql ALTER ROLE miem_publication_import NOLOGIN; SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE usename = 'miem_publication_import' AND pid <> pg_backend_pid(); REVOKE ALL PRIVILEGES ON DATABASE miem_workers FROM miem_publication_import; REVOKE ALL PRIVILEGES ON SCHEMA public FROM miem_publication_import; REVOKE SELECT (id, full_name) ON public.employees FROM miem_publication_import; REVOKE SELECT (id, employee_id, title, year, doi_url, raw_data), INSERT (employee_id, title, year, doi_url, raw_data, source_hash, created_at, updated_at), UPDATE (title, year, doi_url, raw_data, updated_at) ON public.employee_publications FROM miem_publication_import; SELECT format('REVOKE ALL PRIVILEGES ON SEQUENCE %s FROM miem_publication_import', pg_get_serial_sequence('public.employee_publications', 'id')) \gexec DROP ROLE miem_publication_import; ``` После сохранения отчётов и backup удалите только временные секреты подключения: ```sh docker exec miem_workers-api-1 rm /opt/miem-import/pgpass /opt/miem-import/pg_service.conf ``` Эти действия не используют `DROP OWNED` и не удаляют публикации. SQL роли не является миграцией схемы приложения. Если DROP ROLE не проходит, проверьте оставшиеся grants; не применяйте CASCADE/удаление данных.