Compare commits

..
20 Commits
Author SHA1 Message Date
AndreyandClaude Fable 5.1 1da84c9734 🔖 release: 1.6.0
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 04:15:14 +03:00
AndreyandClaude Fable 5.1 82805f7b1a ✨ feat(identity): организации из интерфейса — создание, выбор после входа, /join под ролью app
Кнопка «Добавить организацию» внизу переключателя у логотипа (A1) ведёт на
страницу /organizations/new: логотип, название, часовой пояс, валюта, язык.
Создавший становится владельцем и сразу переключается в новую организацию.
Право — у администратора установки и у владельца или администратора любой
организации; сервер проверяет то же (POST /api/v1/organizations/).

После входа учётная запись с несколькими организациями выбирает, с какой
начать: экран в рамке входа, строки «логотип · название · роль». Прямая
ссылка на организацию экран минует.

tenancy/0035: роль app вставляет организацию только в контексте заранее
выделенного id (как мастер первого запуска) вместо политики «только первая»;
security-barrier каталог invitation_directory — ссылка /join открывается без
контекста, и под ролью app приглашение раньше не находилось вовсе. Тем же
путём язык организации в профиле: членства читаются через каталог входа.

Тесты: создание организации по API, RLS под реальной ролью app (вставка
только в своём контексте, поиск приглашения по токену), e2e переключателя,
страницы создания и экрана выбора. UpdateState исключён из проверки покрытия
демо-набором — одна строка на установку.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 04:14:24 +03:00
AndreyandClaude Opus 5 eca0e52667 ♻️ refactor(ui): один селект и один скин полей на всё приложение
Карточка агента рисовала свои выпадающие селекты: «Группа» и «Провайдер»
жили на собственной вёрстке и своём CSS, а рядом, в модалке создания
агента, стоял общий SelectField. Два стандарта одного элемента успели
разойтись в высоте, подложке, размере текста, подписи и фокусе.

Общий SelectField научился тому, из-за чего карточка писала свой: метке
перед значением (точка группы), невалидному состоянию и режиму без прав
правки, где значение показывается текстом. Форм у него две — обычный
селект и бокс, который держит рамку, когда внутрь нужно поставить точку
или текст вместо контрола. Карточка перешла на него, локальный скин
удалён, кольцо фокуса кадра G2 сохранено.

Геометрия базлайна v2 (поле 36px на подложке n-10, текст 13.5px, подпись
12.5/600) стала дефолтом shared/form-controls.css. До этого дефолтом
были 38px на surface-card, а базлайн добирали поверх профиль, настройки
и модалка создания агента — вместе с карточкой четыре копии одного поля.
Копии сняты, мобильные 44px тоже общие. Заодно шеврон встал по центру
поля: его отступ считался от прежних 38px.

Справочник дизайн-системы обновлён следом, чтобы не описывать геометрию,
которой в приложении больше нет.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 03:59:10 +03:00
AndreyandClaude Fable 5.1 54c35e281a 🔖 release: 1.5.0
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 00:43:11 +03:00
AndreyandClaude Fable 5.1 5396dda8ad ✨ feat(updates): обновление установки из интерфейса
Установка раз в шесть часов и по кнопке проверяет страницу релизов
GitHub. Администратор установки видит баннер о новой версии и ставит
её одной кнопкой; карточка «Обновления» — в «Настройки → Платформа».

Установку выполняет отдельный сервис updater с Docker-сокетом:
backend-app общается с ним только файлами в томе chatballs-updates
(heartbeat, request.json, status.json). Updater принимает лишь релизы
своего репозитория с образами по digest и применяет compose.yaml в
одноразовом контейнере-помощнике, после чего кладёт файл в каталог
установки. Запрос установки попадает в аудит.

Релизный workflow собирает образ updater и пришпиливает его в
compose.yaml; версия бэкенда зашивается в образ (CHATBALLS_VERSION).
Миграции updates.0001 и tenancy.0034 (гранты на таблицу состояния).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 00:43:11 +03:00
AndreyandClaude Fable 5.1 29164db094 🔖 release: 1.4.0
SVG-логотип организации; воркер больше не засоряет журнал сбоями опроса
подключений и не опрашивает демо-подключения.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 00:03:55 +03:00
AndreyandClaude Fable 5.1 c5a53ccec5 ✨ feat(identity): SVG в качестве логотипа организации
К PNG, JPEG и WebP добавлен SVG. Файл проверяется при загрузке и
отклоняется целиком, если содержит скрипты, обработчики событий,
foreignObject, внешние ссылки в href или url() внутри стилей, DOCTYPE и
сущности — переписывать чужую графику молча хуже, чем попросить другой
файл. При отдаче логотип получает Content-Security-Policy с sandbox и
nosniff как второй рубеж: адрес можно открыть и напрямую.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 00:03:54 +03:00
AndreyandClaude Fable 5.1 049217505a 🔇 fix(worker): пауза после сбоя опроса подключения и тишина в журнале
Подключение с ненастоящим токеном или недоступным сервером писало
предупреждение на каждом цикле опроса, каждые три секунды, на каждое
подключение. Теперь сбой транспорта — исключение PollFailed, а слой пауз
(transports.backoff) пропускает подключение с удвоением паузы от шести
секунд до четверти часа и пишет в журнал только смену состояния: первый
сбой, выход на максимальную паузу и восстановление. Курсор при сбое не
двигается. Демо-подключения из демо-набора помечаются demoSeed и не
опрашиваются вовсе: их токены ненастоящие.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 00:03:54 +03:00
AndreyandClaude Fable 5.1 132c684630 🔖 release: 1.3.1
Исправления после установки на чистый хост: «Ошибка загрузки» сразу после
мастера первого запуска (кэш адреса установки в соседних процессах) и
пустое состояние «Базы знаний», прижатое к левому краю.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 23:18:43 +03:00
AndreyandClaude Fable 5.1 bfe90e9abd 💄 fix(knowledge): пустое состояние библиотеки снова по центру
Коммит 776bc895 при переносе библиотеки на серверную пагинацию удалил
правило контейнера .knowledge-empty, оставив только вложенный блок шириной
480px — он прижимался к левому краю рабочей области. Правило возвращено
как было: flex, по центру, отступ 40px (кадр KB1).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 23:18:43 +03:00
AndreyandClaude Fable 5.1 5b49904d6a 🐛 fix(identity): адрес установки виден соседним процессам сразу, а не через 10 секунд
Кэш адреса установки живёт в каждом процессе gunicorn по 10 секунд. Мастер
первого запуска и смена адреса в «Настройках» сбрасывали его только в том
процессе, где выполнялись: соседний отвечал «Invalid host» на первый же
запрос после мастера, и приложение показывало «Ошибка загрузки» до
обновления страницы. Промах по хосту теперь перечитывает строку настроек,
но не чаще раза в секунду на процесс, чтобы поток запросов с чужим Host не
превращался в поток запросов к базе.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 23:18:43 +03:00
AndreyandClaude Fable 5.1 cd50cce5a8 ⬆️ ci(actions): версии actions под Node.js 24
GitHub снимает Node.js 20 с раннеров и уже принудительно запускает старые
версии actions на Node.js 24, о чём предупреждает в каждом прогоне. Все семь
actions подняты до мажорных версий, чей action.yml объявляет node24:
checkout v7, setup-python v7, upload-artifact v7, build-push-action v7,
login-action v4, setup-buildx-action v4, action-gh-release v3. Входы и выходы
шагов не менялись.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 18:55:51 +03:00
AndreyandClaude Fable 5.1 a4ca7e9373 🔖 release: 1.3.0
Несколько организаций на одной установке: переключатель организаций,
приглашения существующих учётных записей и владельца из провижининга,
администратор установки, минимальные привилегии процессов и три тома
секретов. Миграции tenancy 0031–0033, identity 0036–0037; настройки
установки переехали на /api/v1/instance/.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 16:19:01 +03:00
AndreyandClaude Fable 5.1 145c44a17d ✨ feat(internal-ui): несколько организаций — переключатель, разделы администратора установки, приглашения
Переключатель организаций в сайдбаре (дизайн-базлайн v2, A1) показывает все
организации человека, текущая отмечена, выбор другой пересобирает Shell по
ключу организации. Вход без организации в адресе открывает последнюю
открытую или первую по списку вместо экрана «нет доступа»; предпочтение
переехало в localStorage.

Разделы «Платформа» и «Хранилище файлов» видны только администратору
установки, relay для звонков у остальных менеджеров только на чтение;
настройки установки запрашиваются с /api/v1/instance/.

Ссылка-приглашение /join: вошедший принимает приглашение и попадает в новую
организацию, гость без учётной записи задаёт имя и пароль полями мастера
первого запуска. Ожидающие приглашения показаны в списке сотрудников
строками «Приглашён» с меню «отправить ещё раз» и «отозвать» — теми же
элементами, что строка сотрудника.

e2e: проекту internal-ui задана русская локаль браузера, моки сессии отдают
язык установки — сценарии перестали зависеть от языка машины; добавлены
сценарии переключателя, строки приглашения и регистрации гостя.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 14:37:52 +03:00
AndreyandClaude Fable 5.1 ac824e192c 🔒 feat(deploy): три тома секретов и разрешение upstream в nginx на каждом запросе
Том chatballs-secrets целиком монтировался во все сервисы: публичный
backend-app читал пароли платформенной роли и роли миграций, которая обходит
RLS. Теперь томов три — общий, платформенной роли и роли миграций вместе с
паролем владельца кластера — и каждый процесс монтирует только свои.
Генератор переносит файлы существующих установок: копия, побайтная сверка,
только потом удаление из общего тома; повторный запуск ничего не трогает,
пароли не меняются. doctor на работающем стеке проверяет, что backend-app
паролей не видит. Воркер получает алиас platform флагом
CHATBALLS_DB_PLATFORM_ALIAS. README перечисляет три тома в бэкапе.

nginx (dev-шлюз и production-фронтенд) резолвил имя сервиса один раз при
старте: после пересоздания контейнеров старый адрес backend-app достался
backend-platform, и туда уходил WebSocket приложения. Upstream задан
переменной с resolver на DNS Docker. ADR-CHATBALLS-0048 §3, §5.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 14:37:38 +03:00
AndreyandClaude Fable 5.1 3e790af6e5 ✉️ feat(identity): приглашения в организацию — существующие учётные записи и владелец из провижининга
Учётная запись глобальная, а во вторую организацию было не попасть: форма
создания сотрудника отвечала «e-mail занят». Теперь занятый адрес из другой
организации получает приглашение с той же ролью, должностью, телефоном и
группами; членство появляется, когда человек принял его под своим входом.
Письмо отправляет воркер и выпускает токен в момент отправки — открытый
токен нигде не хранится. Повторное приглашение заменяет прежнее.

Приглашение владельца из платформенного провижининга доходит тем же
письмом. По ссылке /join предпросмотр говорит, есть ли учётная запись:
существующая идёт на вход и принимает приглашение, новая задаёт имя и пароль
(регистрация с теми же правилами, что в мастере) и активирует организацию.

Ожидающие приглашения отдаются в списке сотрудников блоком invitations с
действиями «отправить ещё раз» и «отозвать». Ответ на ошибки формы полями
переехал из мастера в auth/common — им пользуется и регистрация.
ADR-CHATBALLS-0047.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 14:37:25 +03:00
AndreyandClaude Fable 5.1 1390ccc426 🔒 fix(tenancy): гранты платформенной роли, backend-app только ролью app, организация видна в своём контексте
Провижининг организаций через платформенный API работал только в тестах:
роль chatballs_platform не имела прав на таблицы платформы, и уже проверка
токена падала с permission denied. Тесты ходят в базу владельцем кластера и
этого не видели — новый тест выполняет запрос под реальной runtime-ролью.

Публичный процесс backend-app держал platform-соединение ради каталогов
входа и мастера первого запуска. Теперь каталоги читаются ролью app
(SELECT на security-barrier вьюхи), а первую организацию мастер создаёт под
политикой «пока организаций нет» через SECURITY DEFINER-функцию; алиас
platform поднимается только у платформенной поверхности и воркера.

Роль app читала таблицу организаций целиком. Политика сужена до текущей
организации: входы без контекста находят id через каталог организаций и
читают строку уже внутри tenant_atomic (tenancy/lookup), воркер обходит
организации по каталогу, мастер выделяет id из последовательности заранее.
Должность владельца при провижининге берётся из словаря на языке
организации. ADR-CHATBALLS-0048.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 14:37:10 +03:00
AndreyandClaude Fable 5.1 2e52bdaea0 👥 feat(identity): администратор установки и настройки инсталляции на /api/v1/instance/
Настройки установки — адрес, почта, TURN, хранилище файлов, язык по
умолчанию — общие для всех организаций, а редактировал их любой владелец
любой организации через путь под организацией. Теперь у учётной записи есть
глобальный признак is_instance_admin: первым его получает владелец из
мастера первого запуска, существующим установкам — те, у кого был
is_superuser. Эндпоинты переехали на /api/v1/instance/ вне tenant
middleware: читает менеджер любой организации (ради карточки relay),
меняет только администратор установки. Аудит и событие переноса файлов —
уровня установки, без организации. Передача признака — командой
set_instance_admin и полем в Django admin; последнего активного
администратора отозвать нельзя.

Сессия отдаёт isInstanceAdmin. Старые пути под организацией отвечают 404.
ADR-CHATBALLS-0046.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 14:36:51 +03:00
AndreyandClaude Opus 5 5bf3d9b572 🔖 release: 1.2.1
Тег на зелёном коммите: в v1.2.0 проверки CI упали на порядке импортов.
Кода релиз не меняет — правка уже в main отдельным коммитом.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 03:36:56 +03:00
AndreyandClaude Opus 5 6d94aa1f9f 🚨 fix(lint): порядок импортов по конфигурации из корня репозитория
Проверка ruff в контейнере шла из /app/apps/backend, где корневого
pyproject.toml нет, — правило I (сортировка импортов) там просто не
применялось, и CI поймал то, что локальный прогон пропустил. Правки чисто
механические: порядок импортов и пустые строки, оставшиеся от удалённых
констант.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 03:20:50 +03:00
167 changed files with 5849 additions and 532 deletions

No files matched your search

+4 -4
View File
@@ -23,9 +23,9 @@ jobs:
name: ruff
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- uses: actions/setup-python@v5
- uses: actions/setup-python@v7
with:
python-version: "3.12"
@@ -46,9 +46,9 @@ jobs:
name: deployment CLI
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- uses: actions/setup-python@v5
- uses: actions/setup-python@v7
with:
python-version: "3.12"
+34 -10
View File
@@ -39,12 +39,13 @@ env:
GATEWAY_BASE_IMAGE: caddy:2.8.4
REDIS_IMAGE: redis:7-alpine
COTURN_IMAGE: coturn/coturn:4.6
UPDATER_BASE_IMAGE: docker:27-cli
jobs:
images:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Версия релиза
id: version
@@ -57,9 +58,9 @@ jobs:
echo "value=$version" >> "$GITHUB_OUTPUT"
echo "repo=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@v3
- uses: docker/setup-buildx-action@v4
- uses: docker/login-action@v3
- uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -78,13 +79,16 @@ jobs:
echo "gateway=${GATEWAY_BASE_IMAGE}@$(digest_of "$GATEWAY_BASE_IMAGE")" >> "$GITHUB_OUTPUT"
echo "redis=${REDIS_IMAGE}@$(digest_of "$REDIS_IMAGE")" >> "$GITHUB_OUTPUT"
echo "coturn=${COTURN_IMAGE}@$(digest_of "$COTURN_IMAGE")" >> "$GITHUB_OUTPUT"
echo "updater=${UPDATER_BASE_IMAGE}@$(digest_of "$UPDATER_BASE_IMAGE")" >> "$GITHUB_OUTPUT"
- name: Backend
id: backend
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: ${{ env.APP_DIR }}
file: ${{ env.APP_DIR }}/apps/backend/Dockerfile.production
build-args: |
CHATBALLS_VERSION=${{ steps.version.outputs.value }}
push: true
tags: |
${{ env.REGISTRY }}/${{ steps.version.outputs.repo }}/backend:${{ steps.version.outputs.value }}
@@ -94,7 +98,7 @@ jobs:
- name: Frontend
id: frontend
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: ${{ env.APP_DIR }}
file: ${{ env.APP_DIR }}/deploy/docker/frontend.Dockerfile
@@ -109,7 +113,7 @@ jobs:
# а не монтируются с хоста. Ради этого установка и стала одним файлом.
- name: Gateway
id: gateway
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: ${{ env.APP_DIR }}
file: ${{ env.APP_DIR }}/deploy/docker/gateway.Dockerfile
@@ -124,7 +128,7 @@ jobs:
- name: Postgres
id: postgres
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: ${{ env.APP_DIR }}
file: ${{ env.APP_DIR }}/deploy/docker/postgres.Dockerfile
@@ -137,6 +141,23 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
# Сервис обновления по кнопке (ADR-CHATBALLS-0049): docker CLI с compose
# и два сценария внутри; единственный, кому монтируется docker.sock.
- name: Updater
id: updater
uses: docker/build-push-action@v7
with:
context: ${{ env.APP_DIR }}
file: ${{ env.APP_DIR }}/deploy/docker/updater.Dockerfile
build-args: |
CHATBALLS_UPDATER_BASE_IMAGE=${{ steps.bases.outputs.updater }}
push: true
tags: |
${{ env.REGISTRY }}/${{ steps.version.outputs.repo }}/updater:${{ steps.version.outputs.value }}
${{ env.REGISTRY }}/${{ steps.version.outputs.repo }}/updater:latest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Релизный compose.yaml и release.env
id: artifacts
run: |
@@ -148,6 +169,7 @@ jobs:
frontend="$prefix/frontend:$version@${{ steps.frontend.outputs.digest }}"
gateway="$prefix/gateway:$version@${{ steps.gateway.outputs.digest }}"
postgres="$prefix/postgres:$version@${{ steps.postgres.outputs.digest }}"
updater="$prefix/updater:$version@${{ steps.updater.outputs.digest }}"
redis="${{ steps.bases.outputs.redis }}"
coturn="${{ steps.bases.outputs.coturn }}"
@@ -160,7 +182,8 @@ jobs:
--pin "CHATBALLS_GATEWAY_IMAGE=$gateway" \
--pin "CHATBALLS_POSTGRES_IMAGE=$postgres" \
--pin "CHATBALLS_REDIS_IMAGE=$redis" \
--pin "CHATBALLS_COTURN_IMAGE=$coturn"
--pin "CHATBALLS_COTURN_IMAGE=$coturn" \
--pin "CHATBALLS_UPDATER_IMAGE=$updater"
{
echo "# release.env — digest-пины релиза $version для \`chatballs deploy\`."
@@ -172,6 +195,7 @@ jobs:
echo "CHATBALLS_POSTGRES_IMAGE=$postgres"
echo "CHATBALLS_REDIS_IMAGE=$redis"
echo "CHATBALLS_COTURN_IMAGE=$coturn"
echo "CHATBALLS_UPDATER_IMAGE=$updater"
} > dist/release.env
# Файл, который скачает человек, обязан быть валидным сам по себе —
@@ -180,7 +204,7 @@ jobs:
cat dist/compose.yaml
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
with:
name: release-compose
path: |
@@ -189,7 +213,7 @@ jobs:
- name: Приложить к релизу
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
files: |
dist/compose.yaml
+5 -3
View File
@@ -132,7 +132,9 @@ By default files are stored in a Docker volume. In **Settings → Storage** the
### Updating
Download the new release's `compose.yaml` over the old one and restart:
When a new release is out, the installation administrator sees a banner in the interface and updates with one button; the same lives in **Settings → Platform → Updates**. The installation updates itself on the server: it downloads the release `compose.yaml`, pulls the images and restarts the services, with about a minute of downtime.
Manually, from the server console: download the new release's `compose.yaml` over the old one and restart:
```bash
docker compose pull && docker compose up -d --wait
@@ -311,13 +313,13 @@ All services should be `healthy` or `running`. Application readiness is availabl
<details>
<summary><strong>Do not delete the secrets volume</strong></summary>
The `chatballs-secrets` volume holds the encryption key. Without it, integration tokens, SMTP passwords, S3 keys and two-factor secrets become unreadable. Include this volume in backups together with the database and files.
The `chatballs-secrets` volume holds the encryption key. Without it, integration tokens, SMTP passwords, S3 keys and two-factor secrets become unreadable. The `chatballs-secrets-platform` and `chatballs-secrets-schema` volumes hold the database role passwords: without them the stack cannot connect to its own database. Include all three volumes in backups together with the database and files.
</details>
<details>
<summary><strong>Backup</strong></summary>
Copy the `chatballs-postgres`, `chatballs-media` and `chatballs-secrets` volumes. A database dump can be taken as well:
Copy the `chatballs-postgres`, `chatballs-media`, `chatballs-secrets`, `chatballs-secrets-platform` and `chatballs-secrets-schema` volumes. A database dump can be taken as well:
```bash
docker compose exec -T postgres pg_dump -U chatballs_bootstrap chatballs > backup.sql
+5 -3
View File
@@ -132,7 +132,9 @@ Relay слушает выделенный IP, чтобы порт 443 не ко
### Обновление
Скачайте `compose.yaml` нового релиза поверх старого и повторите запуск:
Когда выходит новый релиз, администратор установки видит баннер в интерфейсе и обновляется одной кнопкой; то же есть в **Настройки → Платформа → Обновления**. Установка идёт на сервере сама: скачивается `compose.yaml` релиза, загружаются образы, сервисы перезапускаются, приложение недоступно около минуты.
Вручную, из консоли сервера: скачайте `compose.yaml` нового релиза поверх старого и повторите запуск:
```bash
docker compose pull && docker compose up -d --wait
@@ -311,13 +313,13 @@ docker compose ps
<details>
<summary><strong>Не удаляйте том с секретами</strong></summary>
В томе `chatballs-secrets` лежит ключ шифрования. Без него станут нечитаемы токены интеграций, пароли SMTP, ключи S3 и секреты двухфакторной защиты. Включайте этот том в резервные копии вместе с базой и файлами.
В томе `chatballs-secrets` лежит ключ шифрования. Без него станут нечитаемы токены интеграций, пароли SMTP, ключи S3 и секреты двухфакторной защиты. В томах `chatballs-secrets-platform` и `chatballs-secrets-schema` лежат пароли ролей базы: без них стек не подключится к собственной базе. Включайте все три тома в резервные копии вместе с базой и файлами.
</details>
<details>
<summary><strong>Резервная копия</strong></summary>
Копируйте тома `chatballs-postgres`, `chatballs-media` и `chatballs-secrets`. Для базы можно снять дамп:
Копируйте тома `chatballs-postgres`, `chatballs-media`, `chatballs-secrets`, `chatballs-secrets-platform` и `chatballs-secrets-schema`. Для базы можно снять дамп:
```bash
docker compose exec -T postgres pg_dump -U chatballs_bootstrap chatballs > backup.sql
+1 -1
View File
@@ -1 +1 @@
1.2.0
1.6.0
+5
View File
@@ -6,6 +6,11 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
WORKDIR /app
# Версия релиза попадает в образ при сборке: по ней приложение решает,
# есть ли обновление. Сборка из исходников остаётся «dev».
ARG CHATBALLS_VERSION=dev
ENV CHATBALLS_VERSION=${CHATBALLS_VERSION}
RUN addgroup --system hub && adduser --system --ingroup hub hub
COPY apps/backend/requirements.txt /app/apps/backend/requirements.txt
@@ -4,10 +4,10 @@ from rest_framework.request import Request
from rest_framework.views import APIView
from chatballs.ai.models import KnowledgeAttachment
from chatballs.identity.models import Organization
from chatballs.tenancy.context import TenantContext
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.ingress import attachment_route
from chatballs.tenancy.lookup import load_organization
class AttachmentDownloadView(APIView):
@@ -19,10 +19,9 @@ class AttachmentDownloadView(APIView):
route = attachment_route(str(public_id))
if route is None:
raise Http404
try:
organization = Organization.objects.get(pk=route.organization_id)
except Organization.DoesNotExist as error:
raise Http404 from error
organization = load_organization(route.organization_id)
if organization is None:
raise Http404
context = TenantContext.for_resource(organization)
with tenant_atomic(context):
attachment = KnowledgeAttachment.objects.filter(
@@ -21,11 +21,11 @@ at runtime and overrides `AIAgent.model`.
from __future__ import annotations
from chatballs.i18n import t
from chatballs.ai.provider.base import LLMProvider, ProviderError
from chatballs.ai.provider.custom import CustomProvider
from chatballs.ai.provider.demo import DemoProvider
from chatballs.ai.provider.openrouter import OpenRouterProvider
from chatballs.i18n import t
from chatballs.integrations.models import Integration, IntegrationProvider
@@ -14,8 +14,8 @@ from chatballs.calls.models import CallInvite, CallSession, CallStatus, InviteDe
from chatballs.calls.services import CALL_INVITE_SEND
from chatballs.calls.tokens import issue_invite_token
from chatballs.conversations import transports
from chatballs.i18n import customer_language, t
from chatballs.events.handlers import register
from chatballs.i18n import customer_language, t
from chatballs.identity.instance_settings import public_base_url
from chatballs.tenancy.context import TenantContext
@@ -27,10 +27,11 @@ from chatballs.calls.tokens import (
verify_call_access_token,
)
from chatballs.i18n import t
from chatballs.identity.models import Organization, OrganizationMembership
from chatballs.identity.models import OrganizationMembership
from chatballs.tenancy.context import TenantContext
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.ingress import call_invite_route, call_session_route
from chatballs.tenancy.lookup import load_organization
@dataclass(frozen=True)
@@ -45,10 +46,9 @@ def resolve_invite(*, token: str) -> ResolvedInvite:
route = call_invite_route(token_hash)
if route is None:
raise CallTokenError(message)
try:
organization = Organization.objects.get(pk=route.organization_id)
except Organization.DoesNotExist:
raise CallTokenError(message) from None
organization = load_organization(route.organization_id)
if organization is None:
raise CallTokenError(message)
context = TenantContext.for_resource(organization)
with tenant_atomic(context):
invite = (
@@ -92,10 +92,9 @@ def _authorize_call_access(
route = call_session_route(str(claims.call_session_id))
if route is None:
raise CallTokenError(t("calls.token_invalid"))
try:
organization = Organization.objects.get(pk=route.organization_id)
except Organization.DoesNotExist:
raise CallTokenError(t("calls.token_invalid")) from None
organization = load_organization(route.organization_id)
if organization is None:
raise CallTokenError(t("calls.token_invalid"))
resource_context = TenantContext.for_resource(organization)
with tenant_atomic(resource_context):
try:
@@ -22,8 +22,9 @@ from channels.generic.websocket import AsyncJsonWebsocketConsumer
from chatballs.conversations.models import Conversation
from chatballs.conversations.realtime import conversation_group, inbox_group
from chatballs.conversations.selectors import conversation_is_visible
from chatballs.identity.models import Organization, OrganizationMembership
from chatballs.identity.models import OrganizationMembership
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.lookup import organization_by_public_id
logger = logging.getLogger(__name__)
@@ -82,8 +83,10 @@ class ConversationEventsConsumer(AsyncJsonWebsocketConsumer):
@database_sync_to_async
def _membership(self, user_id: int, raw_public_id: str) -> tuple[int, int] | None:
try:
organization = Organization.objects.get(public_id=uuid.UUID(str(raw_public_id)))
except (ValueError, Organization.DoesNotExist):
organization = organization_by_public_id(uuid.UUID(str(raw_public_id)))
except ValueError:
return None
if organization is None:
return None
with tenant_atomic(organization.pk):
membership = (
@@ -22,6 +22,8 @@ def poll_all_messengers(context) -> int:
channel__is_active=True,
).exclude(secret="")
if integration.config.get("purpose") != "notifications"
# Демо-подключения из демо-набора: токены ненастоящие, опрашивать нечего.
and not integration.config.get("demoSeed")
]
total = 0
for integration in integrations:
@@ -19,11 +19,6 @@ from chatballs.integrations.models import IntegrationProvider
from chatballs.tenancy.context import TenantContext
class ClaimError(Exception):
pass
@@ -15,6 +15,7 @@ from chatballs.conversations.models import Contact, Conversation, MessageAuthor
from chatballs.conversations.selectors import conversation_messages
from chatballs.conversations.serializers import conversation_payload, message_payload
from chatballs.conversations.transports import email as email_transport
from chatballs.conversations.transports.errors import PollFailed
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.models import Organization
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
@@ -359,11 +360,11 @@ class EmailPollTests(TestCase):
with mock.patch.object(email_transport, "_imap_connect", side_effect=OSError("refused")):
messages, marker = email_transport.poll_updates(integration)
with self.assertRaises(PollFailed):
email_transport.poll_updates(integration)
self.assertEqual(messages, [])
self.assertEqual(marker, "7:99")
# Курсор не сдвинулся: следующий удачный опрос продолжит с того же места.
self.assertEqual(integration.poll_marker, "7:99")
@@ -0,0 +1,83 @@
"""Сбой опроса подключения: пауза с удвоением и тишина в журнале.
Раньше подключение с ненастоящим токеном писало предупреждение каждые три
секунды. Теперь после сбоя оно пропускается с растущей паузой, журнал видит
первый сбой, выход на максимальную паузу и восстановление, а курсор при
сбое не двигается. Демо-подключения не опрашиваются вовсе.
"""
from __future__ import annotations
from unittest import mock
from django.test import TestCase
from chatballs.channels.models import Channel
from chatballs.conversations import poller
from chatballs.conversations.transports import backoff, poll
from chatballs.conversations.transports.errors import PollFailed
from chatballs.identity.models import Organization
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
from chatballs.tenancy.context import TenantContext
from chatballs.tenancy.database import tenant_atomic
class PollBackoffTests(TestCase):
def setUp(self) -> None:
backoff.reset()
self.addCleanup(backoff.reset)
self.organization = Organization.objects.create(name="Poll", slug="poll-org")
with tenant_atomic(self.organization.id):
self.channel = Channel.objects.create(organization=self.organization, name="Main", code="main")
self.integration = Integration.objects.create(
organization=self.organization,
kind=IntegrationKind.MESSENGER,
provider=IntegrationProvider.TELEGRAM,
name="Bot",
secret="0000:not-a-token",
channel=self.channel,
poll_marker="41",
)
def _fail(self, *_args, **_kwargs):
raise PollFailed("HTTP Error 401: Unauthorized")
def test_failure_is_logged_once_and_then_skipped(self) -> None:
with mock.patch.dict(poller.transports._POLL, {IntegrationProvider.TELEGRAM: self._fail}):
with self.assertLogs("chatballs.conversations.transports.backoff", level="WARNING") as logs:
self.assertEqual(poll(self.integration), ([], "41"))
self.assertEqual(len(logs.output), 1)
self.assertIn("401", logs.output[0])
# Пока пауза не вышла, транспорт не вызывается и журнал молчит.
with mock.patch.object(backoff, "_now", return_value=backoff._now()):
with self.assertNoLogs("chatballs.conversations.transports.backoff", level="WARNING"):
self.assertEqual(poll(self.integration), ([], "41"))
self.assertTrue(backoff.should_skip(self.integration.id))
def test_delay_doubles_up_to_the_cap_and_recovery_is_logged(self) -> None:
clock = [1000.0]
with mock.patch.object(backoff, "_now", side_effect=lambda: clock[0]):
with mock.patch.dict(poller.transports._POLL, {IntegrationProvider.TELEGRAM: self._fail}):
delays = []
for _ in range(10):
poll(self.integration)
state = backoff._failures[self.integration.id]
delays.append(state.delay)
clock[0] = state.next_attempt_at # ждём ровно до следующей попытки
self.assertEqual(delays[:3], [6.0, 12.0, 24.0])
self.assertEqual(delays[-1], backoff.MAX_DELAY_SECONDS)
self.assertTrue(all(delay <= backoff.MAX_DELAY_SECONDS for delay in delays))
with mock.patch.dict(poller.transports._POLL, {IntegrationProvider.TELEGRAM: lambda _i: ([], "42")}):
with self.assertLogs("chatballs.conversations.transports.backoff", level="INFO") as logs:
self.assertEqual(poll(self.integration), ([], "42"))
self.assertIn("recovered", logs.output[0])
self.assertNotIn(self.integration.id, backoff._failures)
def test_demo_seed_connections_are_not_polled(self) -> None:
with tenant_atomic(self.organization.id):
Integration.objects.filter(pk=self.integration.pk).update(config={"demoSeed": True})
context = TenantContext.for_resource(self.organization)
with mock.patch("chatballs.conversations.poller.transports.poll") as polled:
poller.poll_all_messengers(context)
polled.assert_not_called()
@@ -1,6 +1,8 @@
from chatballs.conversations.transports import backoff
from chatballs.conversations.transports import email as _email
from chatballs.conversations.transports import max as _max
from chatballs.conversations.transports import telegram as _telegram
from chatballs.conversations.transports.errors import PollFailed
from chatballs.i18n import t
from chatballs.integrations.models import IntegrationProvider
@@ -51,7 +53,22 @@ SUPPORTED_PROVIDERS = tuple(_POLL.keys())
def poll(integration):
return _POLL[integration.provider](integration)
"""Опрос подключения с паузой после сбоя (transports.backoff).
Сбой транспорта не роняет цикл и не пишется в журнал на каждой попытке:
подключение пропускается с растущей паузой, а журнал видит только смену
состояния. Курсор при сбое не двигается.
"""
if backoff.should_skip(integration.id):
return [], integration.poll_marker
try:
result = _POLL[integration.provider](integration)
except PollFailed as error:
backoff.record_failure(integration, error)
return [], integration.poll_marker
backoff.record_success(integration)
return result
def send_reply(integration, *, chat_id: str, user_id: str, text: str) -> bool:
@@ -0,0 +1,76 @@
"""Пауза между попытками опроса подключения после сбоя.
Воркер опрашивает мессенджеры каждые три секунды. Подключение с отозванным
токеном или недоступным сервером отвечало ошибкой на каждый цикл и писало
её в журнал двадцать раз в минуту — журнал переставал быть читаемым, а
чужой сервер получал бессмысленный поток запросов. Теперь после сбоя
подключение пропускается с растущей паузой, а в журнал попадают только
изменения состояния: первый сбой, выход на максимальную паузу и
восстановление.
Состояние живёт в памяти процесса: воркер один, а после перезапуска первая
попытка всё равно нужна.
"""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass
logger = logging.getLogger(__name__)
# Первая пауза — два цикла опроса, дальше удвоение до четверти часа.
FIRST_DELAY_SECONDS = 6.0
MAX_DELAY_SECONDS = 900.0
@dataclass
class _Failure:
failures: int
next_attempt_at: float
delay: float
_failures: dict[int, _Failure] = {}
def _now() -> float:
return time.monotonic()
def should_skip(integration_id: int) -> bool:
state = _failures.get(integration_id)
return state is not None and _now() < state.next_attempt_at
def record_failure(integration, error: object) -> None:
previous = _failures.get(integration.id)
failures = (previous.failures if previous else 0) + 1
delay = min(FIRST_DELAY_SECONDS * 2 ** (failures - 1), MAX_DELAY_SECONDS)
_failures[integration.id] = _Failure(failures=failures, next_attempt_at=_now() + delay, delay=delay)
if failures == 1:
logger.warning(
"%s poll failed for integration %s: %s (next attempt in %.0fs)",
integration.provider, integration.id, error, delay,
)
elif delay >= MAX_DELAY_SECONDS and (previous is None or previous.delay < MAX_DELAY_SECONDS):
logger.warning(
"%s poll keeps failing for integration %s: %s (retrying every %.0f min)",
integration.provider, integration.id, error, MAX_DELAY_SECONDS / 60,
)
def record_success(integration) -> None:
state = _failures.pop(integration.id, None)
if state is not None:
logger.info(
"%s poll recovered for integration %s after %s failure(s)",
integration.provider, integration.id, state.failures,
)
def reset() -> None:
"""Для тестов: забыть все сбои."""
_failures.clear()
@@ -26,6 +26,7 @@ from chatballs.conversations.transports.base import (
guess_content_type,
safe_filename,
)
from chatballs.conversations.transports.errors import PollFailed
from chatballs.i18n import customer_language, t
logger = logging.getLogger(__name__)
@@ -135,8 +136,7 @@ def poll_updates(integration) -> tuple[list[InboundMessage], str]:
try:
client = _imap_connect(integration)
except (imaplib.IMAP4.error, OSError, TimeoutError) as error:
logger.warning("Email IMAP poll failed for integration %s: %s", integration.id, error)
return [], integration.poll_marker
raise PollFailed(str(error)) from error
try:
client.select("INBOX", readonly=True)
validity = _status_value(client, "UIDVALIDITY")
@@ -162,8 +162,7 @@ def poll_updates(integration) -> tuple[list[InboundMessage], str]:
new_marker = f"{validity}:{uids[-1]}" if uids else integration.poll_marker
return messages, new_marker
except (imaplib.IMAP4.error, OSError, TimeoutError) as error:
logger.warning("Email IMAP poll failed for integration %s: %s", integration.id, error)
return [], integration.poll_marker
raise PollFailed(str(error)) from error
finally:
try:
client.logout()
@@ -0,0 +1,6 @@
class PollFailed(Exception):
"""Опрос подключения не удался: сеть, авторизация, ответ не разобран.
Транспорт не пишет об этом в журнал сам — решение, когда повторить и что
записать, принимает слой пауз между попытками (``transports.backoff``).
"""
@@ -27,6 +27,7 @@ from chatballs.conversations.transports.base import (
request_json_multipart,
safe_filename,
)
from chatballs.conversations.transports.errors import PollFailed
from chatballs.i18n import customer_language, t
from chatballs.integrations.checks import DEFAULT_MAX_BASE_URL
from chatballs.integrations.outbound import host_of
@@ -242,8 +243,7 @@ def poll_updates(integration) -> tuple[list[InboundMessage], str]:
try:
data = request_json(url, headers={"Authorization": token, "Content-Type": "application/json"}, proxy_url=_proxy(integration))
except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException, json.JSONDecodeError) as error:
logger.warning("MAX poll failed for integration %s: %s", integration.id, error)
return [], integration.poll_marker
raise PollFailed(str(error)) from error
updates = data.get("updates") or []
messages = [m for m in (_normalize(u) for u in updates) if m is not None]
new_marker = data.get("marker")
@@ -23,6 +23,7 @@ from chatballs.conversations.transports.base import (
request_json_multipart,
safe_filename,
)
from chatballs.conversations.transports.errors import PollFailed
from chatballs.i18n import customer_language, t
from chatballs.integrations.checks import DEFAULT_TELEGRAM_BASE_URL
from chatballs.integrations.outbound import host_of
@@ -124,8 +125,7 @@ def poll_updates(integration) -> tuple[list[InboundMessage], str]:
try:
data = request_json(url, proxy_url=_proxy(integration))
except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException, json.JSONDecodeError) as error:
logger.warning("Telegram poll failed for integration %s: %s", integration.id, error)
return [], integration.poll_marker
raise PollFailed(str(error)) from error
if not data.get("ok"):
return [], integration.poll_marker
updates = data.get("result") or []
@@ -11,10 +11,11 @@ from chatballs.conversations.poller import poll_all_messengers
from chatballs.events.handlers import dispatch
from chatballs.events.models import OutboxStatus
from chatballs.events.services import claim_next_outbox_event, mark_retry
from chatballs.identity.models import Organization
from chatballs.notifications.binding import poll_notifier_bots
from chatballs.tenancy.context import TenantActorKind, TenantContext
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.lookup import iter_organizations
from chatballs.updates.services import check_for_updates
logger = logging.getLogger(__name__)
@@ -28,7 +29,7 @@ class Command(BaseCommand):
@staticmethod
def _tenant_contexts():
for organization in Organization.objects.order_by("id").iterator():
for organization in iter_organizations():
yield TenantContext.for_resource(
organization, actor_kind=TenantActorKind.SYSTEM
)
@@ -92,6 +93,12 @@ class Command(BaseCommand):
logger.exception("Call sweep cycle failed")
if now - last_maintenance >= MAINTENANCE_INTERVAL:
last_maintenance = now
# Канал релизов спрашивается не чаще раза в несколько часов:
# интервал держит сама проверка по времени последнего ответа.
try:
check_for_updates()
except Exception: # pragma: no cover
logger.exception("Update check cycle failed")
try:
for context in self._tenant_contexts():
with tenant_atomic(context):
+5 -2
View File
@@ -7,8 +7,9 @@ from django.utils import timezone
from chatballs.events.context import get_correlation_id
from chatballs.events.models import EventOwnership, OutboxEvent, OutboxStatus
from chatballs.identity.models import Organization, OrganizationMembership
from chatballs.identity.models import OrganizationMembership
from chatballs.tenancy.context import TenantActorKind, TenantContext
from chatballs.tenancy.lookup import load_organization
@dataclass(frozen=True)
@@ -43,7 +44,9 @@ def tenant_context_for_event(event: OutboxEvent) -> TenantContext | None:
return None
if event.organization_id is None:
raise ValueError("Tenant event has no organization")
organization = Organization.objects.get(pk=event.organization_id)
organization = load_organization(event.organization_id)
if organization is None:
raise ValueError("Tenant event organization does not exist")
try:
actor_kind = TenantActorKind(event.actor_kind)
except ValueError as error:
+14 -1
View File
@@ -82,6 +82,8 @@ MESSAGES: dict[str, object] = {
"emails.initial_access_subject": "Your Chatballs access",
"emails.password_reset_body": "Hello {name},\n\nYou asked to reset your Chatballs password. To set a new one, follow this link:\n{url}\n\nThe link is valid for 30 minutes. If you did not ask for a reset, simply ignore this email.",
"emails.password_reset_subject": "Chatballs access recovery",
"emails.membership_invitation_subject": "Invitation to {organization}",
"emails.membership_invitation_body": "Hello, {name}.\n\nYou are invited to join the organization \u201c{organization}\u201d in Chatballs. To accept the invitation, follow this link:\n{url}\n\nThe link is valid for 7 days. If you did not expect this e-mail, simply ignore it.",
"identity.link_invalid_or_expired": "The link is not valid or has expired",
"identity.password_needs_digit": "The password must contain a digit.",
"identity.password_needs_letter": "The password must contain a letter.",
@@ -208,6 +210,10 @@ MESSAGES: dict[str, object] = {
"audit.action_identity_avatar_updated": "Profile photo changed",
"audit.action_identity_employee_blocked": "Operator blocked",
"audit.action_identity_employee_created": "Operator added",
"audit.action_identity_employee_invited": "Operator invited",
"audit.action_identity_invitation_accepted": "Organization invitation accepted",
"audit.action_identity_invitation_revoked": "Operator invitation revoked",
"audit.action_updates_install_requested": "Installation update started",
"audit.action_identity_employee_groups_changed": "Operator groups changed",
"audit.action_identity_employee_password_reset": "Operator password reset",
"audit.action_identity_employee_privileged_action_denied": "Privileged action denied",
@@ -241,6 +247,7 @@ MESSAGES: dict[str, object] = {
"audit.action_integrations_integration_created": "Integration added",
"audit.action_integrations_integration_deleted": "Integration deleted",
"audit.action_integrations_integration_updated": "Integration changed",
"audit.action_organization_created": "Organization created from the interface",
"audit.action_organization_owner_activated": "Organization owner activated",
"audit.action_organization_owner_invitation_requested": "Owner invitation sent",
"audit.action_organization_provisioned": "Organization created",
@@ -326,7 +333,12 @@ MESSAGES: dict[str, object] = {
"identity.invalid_credentials": "Invalid credentials",
"identity.invalid_totp_code": "Invalid TOTP code",
"identity.invitation_email_mismatch": "Invitation email does not match the account",
"identity.organization_create_forbidden": "Only the installation administrator and organization owners or administrators can create organizations",
"identity.invitation_invalid": "Invitation is invalid or has expired",
"identity.invitation_account_exists": "An account with this address already exists. Sign in with it",
"updates.nothing_to_install": "The latest version is already installed",
"updates.updater_offline": "The update service is not running, so updating from the interface is unavailable",
"updates.install_in_progress": "An update is already in progress",
"identity.role_conflict": "User already has a different role in this organization",
"identity.token_required": "token is required",
"identity.totp_challenge_inactive": "TOTP challenge is not active",
@@ -349,7 +361,8 @@ MESSAGES: dict[str, object] = {
"api.expected_record_id": "{name}: a record identifier is expected",
"settings.storage_unavailable": "The storage is unavailable: {error}",
"admin.actor_system": "System",
"admin.image_formats": "PNG, JPEG and WebP are supported",
"admin.image_formats": "PNG, JPEG, WebP and SVG are supported",
"admin.svg_logo_unsafe": "SVG must not contain scripts, event handlers or external links",
"admin.logo_too_large": "The logo must not exceed 2 MB",
"calls.calls_off_entry_point": "Calls are switched off for this entry point",
"calls.failure_code_required": "FAILED needs a normalized failure_code",
+14 -1
View File
@@ -86,6 +86,8 @@ MESSAGES: dict[str, object] = {
"emails.initial_access_subject": "Первичный доступ к Chatballs",
"emails.password_reset_body": "Здравствуйте, {name}.\n\nВы запросили сброс пароля для Chatballs. Чтобы задать новый пароль, перейдите по ссылке:\n{url}\n\nСсылка действует 30 минут. Если вы не запрашивали сброс, просто проигнорируйте это письмо.",
"emails.password_reset_subject": "Восстановление доступа к Chatballs",
"emails.membership_invitation_subject": "Приглашение в организацию {organization}",
"emails.membership_invitation_body": "Здравствуйте, {name}.\n\nВас приглашают в организацию «{organization}» в Chatballs. Чтобы принять приглашение, перейдите по ссылке:\n{url}\n\nСсылка действует 7 дней. Если вы не ожидали это письмо, просто проигнорируйте его.",
"identity.link_invalid_or_expired": "Ссылка недействительна или истекла",
"identity.password_needs_digit": "Пароль должен содержать цифру.",
"identity.password_needs_letter": "Пароль должен содержать букву.",
@@ -212,6 +214,10 @@ MESSAGES: dict[str, object] = {
"audit.action_identity_avatar_updated": "Изменено фото профиля",
"audit.action_identity_employee_blocked": "Сотрудник заблокирован",
"audit.action_identity_employee_created": "Добавлен сотрудник",
"audit.action_identity_employee_invited": "Отправлено приглашение сотруднику",
"audit.action_identity_invitation_accepted": "Принято приглашение в организацию",
"audit.action_identity_invitation_revoked": "Отозвано приглашение сотрудника",
"audit.action_updates_install_requested": "Запущено обновление установки",
"audit.action_identity_employee_groups_changed": "Изменены группы сотрудника",
"audit.action_identity_employee_password_reset": "Сброшен пароль сотрудника",
"audit.action_identity_employee_privileged_action_denied": "Отказано в привилегированном действии",
@@ -245,6 +251,7 @@ MESSAGES: dict[str, object] = {
"audit.action_integrations_integration_created": "Добавлена интеграция",
"audit.action_integrations_integration_deleted": "Удалена интеграция",
"audit.action_integrations_integration_updated": "Изменена интеграция",
"audit.action_organization_created": "Создана организация из интерфейса",
"audit.action_organization_owner_activated": "Активирован владелец организации",
"audit.action_organization_owner_invitation_requested": "Отправлено приглашение владельцу",
"audit.action_organization_provisioned": "Создана организация",
@@ -330,7 +337,12 @@ MESSAGES: dict[str, object] = {
"identity.invalid_credentials": "Неверный email или пароль",
"identity.invalid_totp_code": "Неверный код",
"identity.invitation_email_mismatch": "Приглашение выписано на другой адрес",
"identity.organization_create_forbidden": "Создавать организации могут администратор установки и владельцы или администраторы организаций",
"identity.invitation_invalid": "Приглашение недействительно или истекло",
"identity.invitation_account_exists": "Учётная запись с этим адресом уже есть — войдите под ней",
"updates.nothing_to_install": "Установлена последняя версия",
"updates.updater_offline": "Сервис обновления не запущен — обновление из интерфейса недоступно",
"updates.install_in_progress": "Обновление уже идёт",
"identity.role_conflict": "У пользователя уже другая роль в этой организации",
"identity.token_required": "Нужен токен",
"identity.totp_challenge_inactive": "Проверка кода уже неактуальна — войдите заново",
@@ -353,7 +365,8 @@ MESSAGES: dict[str, object] = {
"api.expected_record_id": "{name}: ожидается идентификатор записи",
"settings.storage_unavailable": "Хранилище недоступно: {error}",
"admin.actor_system": "Система",
"admin.image_formats": "Поддерживаются PNG, JPEG и WebP",
"admin.image_formats": "Поддерживаются PNG, JPEG, WebP и SVG",
"admin.svg_logo_unsafe": "В SVG не должно быть скриптов, обработчиков событий и внешних ссылок",
"admin.logo_too_large": "Размер логотипа не должен превышать 2 МБ",
"calls.calls_off_entry_point": "Звонки отключены для этой точки входа",
"calls.failure_code_required": "Для FAILED требуется нормализованный failure_code",
+2 -2
View File
@@ -14,12 +14,12 @@ from chatballs.identity.models import (
@admin.register(HumanUser)
class HumanUserAdmin(UserAdmin):
ordering = ["email"]
list_display = ["email", "full_name", "is_active", "is_staff", "last_login"]
list_display = ["email", "full_name", "is_active", "is_staff", "is_instance_admin", "last_login"]
search_fields = ["email", "full_name"]
fieldsets = (
(None, {"fields": ("email", "password")}),
("Personal info", {"fields": ("full_name", "first_name", "last_name")}),
("Permissions", {"fields": ("is_active", "is_staff", "is_superuser", "groups", "user_permissions")}),
("Permissions", {"fields": ("is_active", "is_staff", "is_superuser", "is_instance_admin", "groups", "user_permissions")}),
("Important dates", {"fields": ("last_login", "date_joined")}),
)
add_fieldsets = (
@@ -11,6 +11,7 @@ from django.db import transaction
from chatballs.i18n import t
from chatballs.i18n.languages import normalize_language
from chatballs.identity.logo_svg import SVG_CONTENT_TYPE, looks_like_svg, svg_is_safe
from chatballs.identity.models import Organization
from chatballs.tenancy.context import TenantContext
from chatballs.tenancy.storage import adjust_storage_usage
@@ -34,7 +35,10 @@ class OrganizationSettingsInput:
language: str = ""
def _validate_input(data: OrganizationSettingsInput) -> OrganizationSettingsInput:
def validate_organization_settings(data: OrganizationSettingsInput) -> OrganizationSettingsInput:
"""Имя, часовой пояс, валюта и язык организации — одни правила для
«Настроек» и для страницы создания организации."""
name = data.name.strip()
timezone = data.timezone.strip()
currency = data.currency.strip().upper()
@@ -66,7 +70,7 @@ def update_organization_settings(
context: TenantContext,
data: OrganizationSettingsInput,
) -> Organization:
clean = _validate_input(data)
clean = validate_organization_settings(data)
organization = Organization.objects.select_for_update().get(
pk=context.organization_id
)
@@ -85,6 +89,8 @@ def _image_type(data: bytes) -> tuple[str, str] | None:
return "image/jpeg", ".jpg"
if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return "image/webp", ".webp"
if looks_like_svg(data):
return SVG_CONTENT_TYPE, ".svg"
return None
@@ -102,6 +108,10 @@ def replace_organization_logo(
detected = _image_type(data)
if detected is None:
raise ValidationError({"file": t("admin.image_formats")})
if detected[0] == SVG_CONTENT_TYPE and not svg_is_safe(data):
# Скрипты, внешние ссылки и обработчики событий в логотипе не нужны:
# файл отклоняется целиком, а не переписывается молча.
raise ValidationError({"file": t("admin.svg_logo_unsafe")})
content_type, suffix = detected
organization = Organization.objects.select_for_update().get(
pk=context.organization_id
@@ -107,11 +107,18 @@ class OrganizationLogoView(APIView):
organization = request.tenant_context.organization
if not organization.logo:
return Response({"detail": t("admin.logo_not_uploaded")}, status=404)
return FileResponse(
response = FileResponse(
organization.logo.open("rb"),
content_type=organization.logo_content_type or "application/octet-stream",
filename="organization-logo",
)
# Логотип показывается через <img>, но адрес можно открыть и напрямую.
# SVG проверен при загрузке; заголовки — второй рубеж: в контексте
# документа ему нельзя ни исполнять скрипты, ни ходить наружу, а
# браузеру нельзя угадывать тип.
response["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox"
response["X-Content-Type-Options"] = "nosniff"
return response
def post(self, request: Request) -> Response:
upload = request.FILES.get("file")
@@ -78,6 +78,10 @@ AUDIT_ACTION_LABELS: dict[str, str] = {
"identity.avatar_updated": "audit.action_identity_avatar_updated",
"identity.avatar_deleted": "audit.action_identity_avatar_deleted",
"identity.employee_created": "audit.action_identity_employee_created",
"identity.employee_invited": "audit.action_identity_employee_invited",
"identity.invitation_accepted": "audit.action_identity_invitation_accepted",
"identity.invitation_revoked": "audit.action_identity_invitation_revoked",
"updates.install_requested": "audit.action_updates_install_requested",
"identity.employee_updated": "audit.action_identity_employee_updated",
"identity.employee_blocked": "audit.action_identity_employee_blocked",
"identity.employee_unblocked": "audit.action_identity_employee_unblocked",
@@ -107,6 +111,7 @@ AUDIT_ACTION_LABELS: dict[str, str] = {
"administration.instance_updated": "audit.action_administration_instance_updated",
# --- Организация ---
"organization.provisioned": "audit.action_organization_provisioned",
"organization.created": "audit.action_organization_created",
"organization.owner_activated": "audit.action_organization_owner_activated",
"organization.owner_invitation_requested": "audit.action_organization_owner_invitation_requested",
# --- Интеграции и каналы ---
@@ -1,4 +1,8 @@
from chatballs.identity.auth.invitations import InvitationAcceptView
from chatballs.identity.auth.invitations import (
InvitationAcceptView,
InvitationPreviewView,
InvitationRegisterView,
)
from chatballs.identity.auth.password_reset import (
PasswordResetConfirmView,
PasswordResetRequestView,
@@ -40,4 +44,6 @@ __all__ = [
"TotpConfirmView",
"TotpVerifyView",
"InvitationAcceptView",
"InvitationPreviewView",
"InvitationRegisterView",
]
+29 -10
View File
@@ -1,32 +1,48 @@
from django.conf import settings
from django.core.exceptions import ValidationError
from rest_framework.request import Request
from rest_framework.response import Response
from chatballs.i18n import current_language
from chatballs.i18n import current_language, t
from chatballs.identity.avatars import own_avatar_url
from chatballs.identity.models import HumanUser, Organization, OrganizationMembership
from chatballs.identity.models import HumanUser, OrganizationMembership
from chatballs.identity.policy import get_effective_access
from chatballs.identity.sessions import revoke_user_sessions
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.ingress import membership_routes_for_user
def validation_response(error: ValidationError) -> Response:
"""Ошибки формы полями: мастер первого запуска и регистрация по приглашению."""
if hasattr(error, "message_dict"):
errors = {
key: messages[0] if isinstance(messages, list) else str(messages)
for key, messages in error.message_dict.items()
}
# validate_password кладёт сообщения без ключа поля.
if "__all__" in errors:
errors["password"] = " ".join(error.message_dict["__all__"])
del errors["__all__"]
detail = next(iter(errors.values()), t("setup.check_fields"))
return Response({"detail": detail, "errors": errors}, status=400)
message = " ".join(error.messages)
return Response({"detail": message, "errors": {"password": message}}, status=400)
def _user_payload(user: HumanUser) -> dict[str, object]:
memberships = []
routes = membership_routes_for_user(user.id)
organizations = Organization.objects.in_bulk(
[route.organization_id for route in routes]
)
for route in routes:
organization = organizations.get(route.organization_id)
if organization is None:
continue
with tenant_atomic(organization.id):
# Организация читается вместе с членством внутри её контекста: роль
# app не видит чужие строки организаций (tenancy/0033).
with tenant_atomic(route.organization_id):
membership = (
OrganizationMembership.objects.select_related("organization")
.filter(
id=route.resource_id,
user=user,
organization=organization,
organization_id=route.organization_id,
blocked_at__isnull=True,
)
.first()
@@ -70,6 +86,9 @@ def _user_payload(user: HumanUser) -> dict[str, object]:
"uiLanguage": user.ui_language,
"language": current_language(),
"avatarUrl": own_avatar_url(user),
# Администратор установки видит раздел «Платформа» и хранилище
# файлов: это свойства инсталляции, а не организации.
"isInstanceAdmin": user.is_instance_admin,
"memberships": memberships,
}
@@ -1,17 +1,87 @@
from __future__ import annotations
from rest_framework.permissions import IsAuthenticated
from django.contrib.auth import login
from django.core.exceptions import ValidationError
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_protect, ensure_csrf_cookie
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView
from chatballs.i18n import t
from chatballs.identity.auth.common import _user_payload
from chatballs.identity.invitation_service import InvitationError, accept_invitation
from chatballs.identity.auth.common import _user_payload, validation_response
from chatballs.identity.invitation_service import (
InvitationError,
accept_invitation,
invitation_preview,
register_and_accept,
)
from chatballs.identity.sessions import remember_device
@method_decorator(ensure_csrf_cookie, name="dispatch")
class InvitationPreviewView(APIView):
"""Что стоит за ссылкой /join до входа: организация, адрес, есть ли учётная запись.
Ответ нужен экрану, чтобы решить, показать вход или форму создания пароля.
Токен — секрет из письма, поэтому подробности отдаются только по нему.
"""
authentication_classes: list = []
permission_classes = [AllowAny]
throttle_classes = [ScopedRateThrottle]
throttle_scope = "login"
def get(self, request: Request) -> Response:
token = str(request.query_params.get("token", "")).strip()
preview = invitation_preview(token) if token else None
if preview is None:
return Response({"valid": False})
return Response({"valid": True, **preview})
@method_decorator(csrf_protect, name="dispatch")
class InvitationRegisterView(APIView):
"""Создать учётную запись по приглашению, принять его и войти."""
authentication_classes: list = []
permission_classes = [AllowAny]
throttle_classes = [ScopedRateThrottle]
throttle_scope = "login"
def post(self, request: Request) -> Response:
body = request.data if isinstance(request.data, dict) else {}
token = str(body.get("token", "")).strip()
if not token:
return Response({"detail": t("identity.token_required")}, status=400)
try:
accepted = register_and_accept(
token=token,
full_name=str(body.get("fullName", "")),
password=str(body.get("password", "")),
)
except InvitationError as error:
return Response({"detail": str(error), "code": error.code}, status=400)
except ValidationError as error:
return validation_response(error)
user = accepted.membership.user
user.backend = "django.contrib.auth.backends.ModelBackend"
login(request, user)
remember_device(request)
return Response(
{
"authenticated": True,
"user": _user_payload(user),
"organizationPublicId": str(accepted.organization.public_id),
},
status=201,
)
class InvitationAcceptView(APIView):
"""Accept an OWNER invitation (SPEC-HUB-0021 §8.2).
"""Accept an organization invitation: OWNER (SPEC-HUB-0021 §8.2) or employee.
Authenticated endpoint: the caller must already have a HumanUser account
(created through sign-up / password setup). The token is read from the body;
@@ -26,7 +96,13 @@ class InvitationAcceptView(APIView):
if not token:
return Response({"detail": t("identity.token_required")}, status=400)
try:
accept_invitation(token=token, user=request.user)
accepted = accept_invitation(token=token, user=request.user)
except InvitationError as error:
return Response({"detail": str(error)}, status=400)
return Response({"user": _user_payload(request.user)})
return Response(
{
"user": _user_payload(request.user),
# Куда открыть приложение после принятия.
"organizationPublicId": str(accepted.organization.public_id),
}
)
@@ -17,7 +17,8 @@ from chatballs.identity.avatars import delete_user_avatar, replace_user_avatar
from chatballs.identity.instance_settings import default_language
from chatballs.identity.models import HumanUser, OrganizationMembership
from chatballs.identity.sessions import list_user_sessions
from chatballs.tenancy.ingress import user_requires_totp
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.ingress import membership_routes_for_user, user_requires_totp
class ProfileUpdateView(APIView):
@@ -289,10 +290,20 @@ def _request_organization_language(request: Request) -> str:
context = getattr(request, "tenant_context", None)
if context is not None:
return context.organization.language or ""
membership = (
OrganizationMembership.objects.select_related("organization")
.filter(user=request.user, blocked_at__isnull=True)
.order_by("created_at", "id")
.first()
)
return membership.organization.language if membership is not None else ""
# Членства роли app без контекста не видны: сначала каталог входа, затем
# каждое членство читается в контексте своей организации — как в
# identity.auth.common._user_payload.
oldest: tuple[object, int, str] | None = None
for route in membership_routes_for_user(request.user.id):
with tenant_atomic(route.organization_id):
membership = (
OrganizationMembership.objects.select_related("organization")
.filter(id=route.resource_id, user=request.user, blocked_at__isnull=True)
.first()
)
if membership is None:
continue
key = (membership.created_at, membership.id, membership.organization.language or "")
if oldest is None or key[:2] < oldest[:2]:
oldest = key
return oldest[2] if oldest is not None else ""
@@ -23,4 +23,6 @@ urlpatterns = [
path("totp/confirm/", auth.TotpConfirmView.as_view(), name="auth-totp-confirm"),
path("totp/verify/", auth.TotpVerifyView.as_view(), name="auth-totp-verify"),
path("invitations/accept/", auth.InvitationAcceptView.as_view(), name="auth-invitation-accept"),
path("invitations/preview/", auth.InvitationPreviewView.as_view(), name="auth-invitation-preview"),
path("invitations/register/", auth.InvitationRegisterView.as_view(), name="auth-invitation-register"),
]
+5 -2
View File
@@ -38,9 +38,12 @@ def user_avatar_url(user: HumanUser | None, organization_public_id) -> str | Non
@functools.lru_cache(maxsize=4096)
def organization_public_id(organization_id: int) -> str:
"""public_id организации по id — неизменяем, поэтому кэшируется."""
from chatballs.identity.models import Organization
from chatballs.tenancy.ingress import organization_public_id_of
return str(Organization.objects.values_list("public_id", flat=True).get(pk=organization_id))
public_id = organization_public_id_of(organization_id)
if public_id is None:
raise LookupError(f"Organization {organization_id} does not exist")
return public_id
def user_avatar_url_in(user: HumanUser | None, organization_id: int) -> str | None:
+4 -2
View File
@@ -50,15 +50,17 @@ def bootstrap_owner(*, email: str, password: str, full_name: str = "") -> Bootst
"full_name": full_name,
"is_staff": True,
"is_superuser": True,
"is_instance_admin": True,
},
)
if created_owner:
owner.set_password(password)
owner.save(update_fields=["password"])
elif not owner.is_staff or not owner.is_superuser:
elif not owner.is_staff or not owner.is_superuser or not owner.is_instance_admin:
owner.is_staff = True
owner.is_superuser = True
owner.save(update_fields=["is_staff", "is_superuser"])
owner.is_instance_admin = True
owner.save(update_fields=["is_staff", "is_superuser", "is_instance_admin"])
OrganizationMembership.objects.get_or_create(
user=owner,
@@ -4,10 +4,8 @@ from chatballs.identity import (
administration_views,
demo_views,
group_views,
instance_views,
)
from chatballs.integrations import feature_views
from chatballs.tenancy import storage_views
urlpatterns = [
path("groups/", group_views.GroupListView.as_view(), name="group-list"),
@@ -17,16 +15,8 @@ urlpatterns = [
administration_views.OrganizationSettingsView.as_view(),
name="organization-settings",
),
path(
"administration/instance/",
instance_views.InstanceAddressView.as_view(),
name="instance-address",
),
path(
"administration/instance/email-check/",
instance_views.InstanceEmailCheckView.as_view(),
name="instance-email-check",
),
# Настройки установки (адрес, почта, TURN, хранилище) — не свойства
# организации: они живут на /api/v1/instance/ (identity.instance_urls).
path(
"administration/logo/",
administration_views.OrganizationLogoView.as_view(),
@@ -39,9 +29,6 @@ urlpatterns = [
),
path("demo/", demo_views.DemoDataView.as_view(), name="organization-demo-data"),
path("administration/communication/", feature_views.CommunicationSettingsView.as_view(), name="communication-settings"),
path("administration/storage/", storage_views.StorageSettingsView.as_view(), name="storage-settings"),
path("administration/storage/check/", storage_views.StorageCheckView.as_view(), name="storage-check"),
path("administration/storage/migrate/", storage_views.StorageMigrateView.as_view(), name="storage-migrate"),
path(
"administration/audit/",
administration_views.AuditListView.as_view(),
@@ -115,7 +115,8 @@ def _ensure_connection(context: TenantContext, refs: DemoRefs, item: dict, curre
provider=item["provider"],
name=item["name"],
secret=item.get("secret", ""),
config=item.get("config", {}),
# Токены демо-подключений ненастоящие: воркер их не опрашивает.
config={**item.get("config", {}), "demoSeed": True},
),
)
Integration.objects.filter(pk=integration.pk).update(
@@ -8,6 +8,7 @@ foundation → channels/ai → support → conversations → operations.
from __future__ import annotations
from chatballs.i18n import customer_language
from chatballs.identity.demo_seed.loaders import (
channels_ai as channels_ai_loader,
)
@@ -23,7 +24,6 @@ from chatballs.identity.demo_seed.loaders import (
from chatballs.identity.demo_seed.loaders import (
support as support_loader,
)
from chatballs.i18n import customer_language
from chatballs.identity.demo_seed.refs import DemoRefs
from chatballs.tenancy.context import TenantContext
+35
View File
@@ -12,6 +12,7 @@ from chatballs.identity.instance_settings import (
email_from_address,
public_base_url,
)
from chatballs.identity.invitation_models import OrganizationInvitation
from chatballs.identity.models import HumanUser
@@ -60,6 +61,40 @@ def send_initial_access_email(user: HumanUser) -> None:
)
def send_membership_invitation_email(
invitation: OrganizationInvitation, token: str, user: HumanUser | None = None
) -> None:
"""Приглашение в организацию по ссылке /join.
Существующая учётная запись входит под своим паролем, новая создаёт его
по той же ссылке. Язык — получателя, если он известен, дальше
приглашающей организации.
"""
join_url = f"{public_base_url()}/join?token={token}"
language = resolve_language(
user_language=user.ui_language if user else "",
organization_language=invitation.organization.language,
instance_language=default_language(),
)
with translation.override(language):
send_mail(
subject=t(
"emails.membership_invitation_subject",
organization=invitation.organization.name,
),
message=t(
"emails.membership_invitation_body",
name=(user.full_name if user else "") or invitation.email,
organization=invitation.organization.name,
url=join_url,
),
from_email=email_from_address(),
recipient_list=[invitation.email],
connection=email_connection(),
)
def send_password_reset_email(user: HumanUser) -> None:
reset_url = _password_setup_url(user)
with translation.override(_recipient_language(user)):
@@ -0,0 +1,159 @@
"""Ожидающие приглашения в списке сотрудников (кадры E1/E2, статус «Приглашён»).
Приглашение существующей учётной записи ещё не членство, но администратор
должен его видеть там же, где сотрудников: строкой с той же ролью, должностью
и группами, что придут после принятия. Отсюда же приглашение отправляют ещё
раз или отзывают.
"""
from __future__ import annotations
from django.utils import timezone
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from chatballs.events.services import DomainEvent, enqueue_event
from chatballs.i18n import t
from chatballs.identity.audit import record_audit_event
from chatballs.identity.avatars import user_avatar_url
from chatballs.identity.employee_selectors import ROLE_ANY
from chatballs.identity.governance import can_create_role
from chatballs.identity.group_models import EmployeeGroup
from chatballs.identity.invitation_models import OrganizationInvitation
from chatballs.identity.invitation_service import (
MEMBERSHIP_INVITATION_REQUESTED,
MEMBERSHIP_INVITATION_TTL,
)
from chatballs.identity.models import HumanUser, Organization, OrganizationMembership
from chatballs.identity.policy import has_capability_any_scope
def _pending(organization_id: int):
return OrganizationInvitation.objects.filter(
organization_id=organization_id,
accepted_at__isnull=True,
revoked_at__isnull=True,
expires_at__gt=timezone.now(),
)
def pending_invitations_payload(organization: Organization, params) -> list[dict[str, object]]:
"""Строки приглашений с теми же фильтрами, что у списка сотрудников."""
invitations = list(_pending(organization.id).order_by("email"))
if not invitations:
return []
users = {
user.email.lower(): user
for user in HumanUser.objects.filter(
email__in=[invitation.email for invitation in invitations]
)
}
group_ids = {gid for invitation in invitations for gid in invitation.group_ids}
groups = {
group.id: group
for group in EmployeeGroup.objects.filter(organization=organization, id__in=group_ids)
}
role = params.get("role")
group_filter = params.get("group")
query = params.get("q", "").strip().lower()
rows: list[dict[str, object]] = []
for invitation in invitations:
user = users.get(invitation.email.lower())
if user is None:
continue
if role and role != ROLE_ANY and invitation.role != role:
continue
if group_filter and group_filter != ROLE_ANY and str(group_filter).isdigit():
if int(group_filter) not in invitation.group_ids:
continue
haystack = f"{user.full_name} {user.email} {invitation.position_title}".lower()
if query and query not in haystack:
continue
rows.append(
{
"id": invitation.id,
"email": user.email,
"fullName": user.full_name,
"avatarUrl": user_avatar_url(user, organization.public_id),
"role": invitation.role,
"positionTitle": invitation.position_title,
"phone": invitation.phone,
"groups": [
{"id": gid, "name": groups[gid].name}
for gid in invitation.group_ids
if gid in groups
],
"invitedAt": invitation.created_at.isoformat(),
"expiresAt": invitation.expires_at.isoformat(),
}
)
return rows
def _load_for_action(request: Request, invitation_id: int) -> OrganizationInvitation | Response:
actor: OrganizationMembership = request.tenant_context.membership
if not has_capability_any_scope(actor, "employees.manage"):
return Response({"detail": t("admin.not_allowed")}, status=403)
invitation = _pending(actor.organization_id).filter(pk=invitation_id).first()
if invitation is None:
return Response({"detail": t("identity.invitation_invalid")}, status=404)
# Приглашение с ролью, которую актор выдать не вправе, ему и не отозвать.
if not can_create_role(actor, invitation.role):
return Response({"detail": t("admin.not_allowed")}, status=403)
return invitation
class InvitationResendView(APIView):
"""Отправить письмо ещё раз: срок продлевается, токен выпускает воркер."""
permission_classes = [IsAuthenticated]
def post(self, request: Request, invitation_id: int) -> Response:
loaded = _load_for_action(request, invitation_id)
if isinstance(loaded, Response):
return loaded
loaded.expires_at = timezone.now() + MEMBERSHIP_INVITATION_TTL
loaded.save(update_fields=["expires_at"])
record_audit_event(
action="identity.employee_invited",
actor=request.user,
organization=loaded.organization,
object_type="OrganizationInvitation",
object_id=str(loaded.id),
payload={"role": loaded.role, "resend": True},
request=request,
)
enqueue_event(
DomainEvent(
aggregate_type="OrganizationInvitation",
aggregate_id=str(loaded.id),
event_type=MEMBERSHIP_INVITATION_REQUESTED,
payload={"invitationId": loaded.id},
tenant_context=request.tenant_context,
)
)
return Response({"ok": True})
class InvitationRevokeView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request: Request, invitation_id: int) -> Response:
loaded = _load_for_action(request, invitation_id)
if isinstance(loaded, Response):
return loaded
loaded.revoked_at = timezone.now()
loaded.save(update_fields=["revoked_at"])
record_audit_event(
action="identity.invitation_revoked",
actor=request.user,
organization=loaded.organization,
object_type="OrganizationInvitation",
object_id=str(loaded.id),
payload={"role": loaded.role},
request=request,
)
return Response({"ok": True})
@@ -1,6 +1,7 @@
from django.urls import path
from chatballs.identity import (
employee_invitations,
employee_security_views,
employee_views,
ownership_views,
@@ -11,6 +12,9 @@ urlpatterns = [
# Создание сотрудника (ADMIN/EMPLOYEE по policy). Путь operators/ сохранён для
# обратной совместимости; поле role в теле выбирает системную роль.
path("operators/", employee_views.EmployeeCreateView.as_view(), name="employee-create"),
# Ожидающие приглашения существующих учётных записей (статус «Приглашён»).
path("invitations/<int:invitation_id>/resend/", employee_invitations.InvitationResendView.as_view(), name="employee-invitation-resend"),
path("invitations/<int:invitation_id>/revoke/", employee_invitations.InvitationRevokeView.as_view(), name="employee-invitation-revoke"),
path("<int:user_id>/", employee_views.EmployeeDetailView.as_view(), name="employee-detail"),
path("<int:user_id>/avatar/", employee_views.EmployeeAvatarView.as_view(), name="employee-avatar"),
path("<int:user_id>/update/", employee_views.EmployeeUpdateView.as_view(), name="employee-update"),
@@ -9,6 +9,7 @@ from chatballs.api.pagination import page_payload, paginate
from chatballs.events.services import DomainEvent, enqueue_event
from chatballs.i18n import t
from chatballs.identity.audit import record_audit_event
from chatballs.identity.employee_invitations import pending_invitations_payload
from chatballs.identity.employee_password import clean_password_mode, issue_initial_password
from chatballs.identity.employee_selectors import employees_for
from chatballs.identity.employee_support import employee_payload, get_owned_profile
@@ -21,6 +22,7 @@ from chatballs.identity.employee_validation import (
from chatballs.identity.event_handlers import INITIAL_ACCESS_REQUESTED
from chatballs.identity.governance import EmployeeAction, can_create_role, can_manage_employee
from chatballs.identity.group_models import EmployeeGroupMember
from chatballs.identity.invitation_service import invite_existing_user
from chatballs.identity.models import EmployeeRole, HumanUser, OrganizationMembership
from chatballs.identity.policy import has_capability_any_scope
@@ -72,7 +74,13 @@ class EmployeeListView(APIView):
employees_for(actor.organization_id, request.query_params),
request.query_params,
)
return Response(page_payload(page, lambda employee: employee_payload(employee, actor)))
payload = page_payload(page, lambda employee: employee_payload(employee, actor))
# Ожидающие приглашения — строками со статусом «Приглашён»: их мало,
# они не листаются и показываются на первой странице.
payload["invitations"] = pending_invitations_payload(
actor.organization, request.query_params
)
return Response(payload)
class EmployeeCreateView(APIView):
@@ -107,13 +115,33 @@ class EmployeeCreateView(APIView):
return Response({"detail": t("admin.temporary_passwords_unsupported")}, status=400)
if password_mode is None:
return Response({"detail": t("admin.unknown_password_mode")}, status=400)
if HumanUser.objects.filter(email=email).exists():
return Response({"detail": t("admin.email_taken")}, status=400)
groups, groups_error = resolve_groups(actor.organization, body.get("groupIds"))
if groups_error:
return Response({"detail": groups_error}, status=400)
existing = HumanUser.objects.filter(email=email).first()
if existing is not None:
# Учётная запись глобальная, а вход в организацию — по согласию
# человека: вместо «e-mail занят» уходит приглашение с той же ролью
# и должностью, членство появится после его принятия. В своей
# организации адрес и правда занят.
if not existing.is_active or OrganizationMembership.objects.filter(
user=existing, organization=actor.organization
).exists():
return Response({"detail": t("admin.email_taken")}, status=400)
invite_existing_user(
organization=actor.organization,
user=existing,
role=requested_role,
position_title=position_title,
phone=phone,
groups=groups or [],
created_by=actor,
context=request.tenant_context,
request=request,
)
return Response({"employee": None, "password": None, "invited": True}, status=201)
user = HumanUser.objects.create_user(
email=email,
password=None,
@@ -1,5 +1,15 @@
from chatballs.events.handlers import register
from chatballs.identity.emails import send_initial_access_email, send_password_reset_email
from chatballs.identity.emails import (
send_initial_access_email,
send_membership_invitation_email,
send_password_reset_email,
)
from chatballs.identity.invitation_models import OrganizationInvitation
from chatballs.identity.invitation_service import (
MEMBERSHIP_INVITATION_REQUESTED,
OWNER_INVITATION_REQUESTED,
refresh_invitation_token,
)
from chatballs.identity.models import HumanUser
from chatballs.tenancy.context import TenantContext
@@ -39,6 +49,37 @@ def handle_password_reset_requested(payload: dict, context: TenantContext | None
send_password_reset_email(user)
def _send_invitation(payload: dict, context: TenantContext | None, *, require_account: bool) -> None:
if context is None:
raise ValueError("Invitation is a tenant event")
invitation = (
OrganizationInvitation.objects.select_related("organization")
.filter(pk=payload.get("invitationId"), organization_id=context.organization_id)
.first()
)
if invitation is None or not invitation.is_pending:
return
user = HumanUser.objects.filter(email__iexact=invitation.email, is_active=True).first()
if user is None and require_account:
return
# Токен выпускается здесь, в момент отправки: открытый токен нигде не
# хранится, а письмо уходит позже, чем приглашение выписано.
token = refresh_invitation_token(invitation)
send_membership_invitation_email(invitation, token, user)
@register(MEMBERSHIP_INVITATION_REQUESTED)
def handle_membership_invitation_requested(payload: dict, context: TenantContext | None) -> None:
# Сотрудника приглашают только с существующей учётной записью.
_send_invitation(payload, context, require_account=True)
@register(OWNER_INVITATION_REQUESTED)
def handle_owner_invitation_requested(payload: dict, context: TenantContext | None) -> None:
# Владельца из провижининга учётная запись может ждать: он создаст её по ссылке.
_send_invitation(payload, context, require_account=False)
# --- Демо-данные (мастер первого запуска и «Настройки») -----------------------
DEMO_INSTALL_REQUESTED = "demo.install_requested"
@@ -0,0 +1,77 @@
"""Доступ к настройкам установки: адрес, почта, TURN, хранилище файлов.
Это свойства инсталляции, а не организации, поэтому право их менять не
выводится из роли в организации: владелец одной организации не должен
переключать SMTP или бакет, общие для всех. Менять их может только
администратор установки — глобальный признак на учётной записи
(``HumanUser.is_instance_admin``). Первым его получает владелец из мастера
первого запуска; дальше признак передают командой ``set_instance_admin``.
Читать настройки без секретов может тот, кто видит «Настройки» хотя бы в одной
организации: карточка relay для звонков показывает адреса TURN администратору
организации, менять их он не может.
"""
from __future__ import annotations
from rest_framework.permissions import SAFE_METHODS, BasePermission
from rest_framework.request import Request
from rest_framework.views import APIView
from chatballs.identity.models import HumanUser, OrganizationMembership
from chatballs.identity.policy import has_capability_any_scope
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.ingress import membership_routes_for_user
INSTANCE_SETTINGS_VIEW_CAPABILITY = "settings.view"
def is_instance_admin(user: object) -> bool:
return (
isinstance(user, HumanUser)
and user.is_active
and bool(getattr(user, "is_instance_admin", False))
)
def can_view_instance_settings(user: object) -> bool:
"""Администратор установки или менеджер хотя бы одной организации.
Членства — тенантные строки: каждое читается в контексте своей организации,
как при сборке сессии.
"""
if is_instance_admin(user):
return True
if not isinstance(user, HumanUser) or not user.is_active:
return False
for route in membership_routes_for_user(user.id):
with tenant_atomic(route.organization_id):
membership = (
OrganizationMembership.objects.select_related("user")
.filter(id=route.resource_id, user=user, blocked_at__isnull=True)
.first()
)
if membership is not None and has_capability_any_scope(
membership, INSTANCE_SETTINGS_VIEW_CAPABILITY
):
return True
return False
class InstanceSettingsPermission(BasePermission):
"""GET — менеджеру любой организации, изменения — администратору установки."""
message = "Instance administrator rights are required"
def has_permission(self, request: Request, view: APIView) -> bool:
if request.method in SAFE_METHODS:
return can_view_instance_settings(request.user)
return is_instance_admin(request.user)
class IsInstanceAdmin(BasePermission):
message = "Instance administrator rights are required"
def has_permission(self, request: Request, view: APIView) -> bool:
return is_instance_admin(request.user)
@@ -72,8 +72,12 @@ class InstanceSettings(models.Model):
_CACHE_TTL_SECONDS = 10.0
# Перечитать кэш при промахе по хосту можно не чаще раза в секунду на процесс:
# иначе поток запросов с чужим Host превращался бы в поток запросов к базе.
_MISS_REFRESH_SECONDS = 1.0
_lock = threading.Lock()
_cached: tuple[float, tuple[str, str]] | None = None
_last_miss_refresh = 0.0
def invalidate_cache() -> None:
@@ -112,6 +116,31 @@ def accepted_hosts() -> tuple[str, ...]:
return tuple(host for host in _hosts() if host)
def host_is_accepted(host: str) -> bool:
"""Свой ли это адрес — с перечитыванием кэша при промахе.
Кэш живёт в каждом процессе gunicorn отдельно. Мастер первого запуска или
смена адреса в «Настройках» сбрасывают его только там, где выполнялись;
соседний процесс до 10 секунд отвечал бы «Invalid host» на адрес, который
установка уже считает своим. Поэтому промах — повод перечитать строку, но
не чаще раза в секунду.
"""
global _last_miss_refresh
if not host:
return False
known = {normalize_domain(item) for item in accepted_hosts()}
if host in known:
return True
now = time.monotonic()
with _lock:
if now - _last_miss_refresh < _MISS_REFRESH_SECONDS:
return False
_last_miss_refresh = now
invalidate_cache()
return host in {normalize_domain(item) for item in accepted_hosts()}
def default_language() -> str:
"""Язык установки: экраны до входа и умолчание для организаций.
@@ -0,0 +1,33 @@
"""Настройки установки: путь без организации в адресе.
Адрес, почта, TURN и хранилище файлов общие для всех организаций, поэтому
они не живут под ``/api/v1/organizations/<uuid>/`` и не проходят tenant
middleware. Доступ — по признаку администратора установки
(см. ``identity.instance_access``).
"""
from django.urls import path
from chatballs.identity import instance_views
from chatballs.tenancy import storage_views
from chatballs.updates import views as update_views
urlpatterns = [
path("settings/", instance_views.InstanceAddressView.as_view(), name="instance-settings"),
path(
"settings/email-check/",
instance_views.InstanceEmailCheckView.as_view(),
name="instance-email-check",
),
path("storage/", storage_views.StorageSettingsView.as_view(), name="instance-storage"),
path("storage/check/", storage_views.StorageCheckView.as_view(), name="instance-storage-check"),
path(
"storage/migrate/",
storage_views.StorageMigrateView.as_view(),
name="instance-storage-migrate",
),
# Обновления установки: состояние, проверка канала, установка по кнопке.
path("update/", update_views.UpdateStateView.as_view(), name="instance-update"),
path("update/check/", update_views.UpdateCheckView.as_view(), name="instance-update-check"),
path("update/install/", update_views.UpdateInstallView.as_view(), name="instance-update-install"),
]
@@ -12,9 +12,9 @@ from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from chatballs.api.permissions import HasCapability
from chatballs.i18n import t
from chatballs.i18n.languages import DEFAULT_LANGUAGE, LANGUAGES, normalize_language
from chatballs.identity.instance_access import InstanceSettingsPermission, IsInstanceAdmin
from chatballs.identity.instance_settings import (
InstanceSettings,
email_connection,
@@ -58,8 +58,9 @@ def instance_payload(row: InstanceSettings) -> dict:
class InstanceAddressView(APIView):
permission_classes = [HasCapability]
required_capabilities = {"GET": "settings.view", "PATCH": "company.manage"}
# Путь без организации: читает менеджер любой организации, меняет только
# администратор установки (identity.instance_access).
permission_classes = [InstanceSettingsPermission]
def get(self, request: Request) -> Response:
return Response({"instance": instance_payload(InstanceSettings.load())})
@@ -171,8 +172,7 @@ class InstanceEmailCheckView(APIView):
получил приглашение.
"""
permission_classes = [HasCapability]
required_capability = "company.manage"
permission_classes = [IsInstanceAdmin]
def post(self, request: Request) -> Response:
from django.core.mail import send_mail
@@ -19,6 +19,12 @@ class OrganizationInvitation(models.Model):
)
email = models.EmailField()
role = models.CharField(max_length=32, choices=EmployeeRole.choices)
# Поля будущего членства: приглашение существующего пользователя несёт
# то же, что форма создания сотрудника, а членство собирается из них при
# принятии. Группы — по id: к моменту принятия часть могла исчезнуть.
position_title = models.CharField(max_length=120, blank=True, default="")
phone = models.CharField(max_length=32, blank=True, default="")
group_ids = models.JSONField(default=list, blank=True)
token_hash = models.CharField(max_length=128, unique=True)
expires_at = models.DateTimeField()
created_by = models.ForeignKey(
@@ -3,13 +3,18 @@ from __future__ import annotations
import hashlib
import secrets
from dataclasses import dataclass
from datetime import datetime
from datetime import datetime, timedelta
from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError
from django.db import transaction
from django.utils import timezone
from chatballs.events.services import DomainEvent, enqueue_event
from chatballs.i18n import t
from chatballs.i18n.audience import customer_language
from chatballs.identity.audit import record_audit_event
from chatballs.identity.group_models import EmployeeGroup, EmployeeGroupMember
from chatballs.identity.models import (
EmployeeRole,
HumanUser,
@@ -18,7 +23,17 @@ from chatballs.identity.models import (
OrganizationMembership,
OrganizationStatus,
)
from chatballs.tenancy.context import TenantContext
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.ingress import invitation_route
# Приглашение существующего пользователя в организацию: письмо отправляет
# воркер по этому событию (identity.event_handlers).
MEMBERSHIP_INVITATION_REQUESTED = "identity.membership_invitation_requested"
# Приглашение владельца из платформенного провижининга (SPEC-HUB-0021 §8.2):
# учётной записи может ещё не быть, тогда человек создаёт её по ссылке.
OWNER_INVITATION_REQUESTED = "organization.owner_invitation_requested"
MEMBERSHIP_INVITATION_TTL = timedelta(days=7)
def _token_hash(token: str) -> str:
@@ -54,15 +69,19 @@ def issue_invitation(
role: str,
expires_at: datetime,
created_by: OrganizationMembership | None,
position_title: str = "",
phone: str = "",
group_ids: list[int] | None = None,
) -> IssuedInvitation:
normalized_email = email.strip().lower()
now = timezone.now()
# Повторное приглашение на тот же адрес заменяет прежнее: старое письмо
# перестаёт работать, а не живёт параллельно с новым.
OrganizationInvitation.objects.filter(
organization=organization,
email__iexact=normalized_email,
accepted_at__isnull=True,
revoked_at__isnull=True,
expires_at__lte=now,
).update(revoked_at=now)
token = secrets.token_urlsafe(32)
invitation = OrganizationInvitation.objects.create(
@@ -72,27 +91,160 @@ def issue_invitation(
token_hash=_token_hash(token),
expires_at=expires_at,
created_by=created_by,
position_title=position_title,
phone=phone,
group_ids=list(group_ids or []),
)
return IssuedInvitation(invitation=invitation, token=token)
def refresh_invitation_token(invitation: OrganizationInvitation) -> str:
"""Новый токен взамен прежнего.
Открытый токен нигде не хранится, а письмо уходит из воркера позже, чем
приглашение выписано, — поэтому воркер выпускает токен сам, в момент
отправки, и старый перестаёт действовать.
"""
token = secrets.token_urlsafe(32)
invitation.token_hash = _token_hash(token)
invitation.save(update_fields=["token_hash"])
return token
def invite_existing_user(
*,
organization: Organization,
user: HumanUser,
role: str,
position_title: str,
phone: str,
groups: list[EmployeeGroup],
created_by: OrganizationMembership | None,
context: TenantContext,
request=None,
) -> OrganizationInvitation:
"""Пригласить уже существующую учётную запись в организацию.
Учётная запись глобальная, а членство появляется только с согласия
человека: он получает письмо и принимает приглашение под своим входом.
"""
issued = issue_invitation(
organization=organization,
email=user.email,
role=role,
expires_at=timezone.now() + MEMBERSHIP_INVITATION_TTL,
created_by=created_by,
position_title=position_title,
phone=phone,
group_ids=[group.id for group in groups],
)
record_audit_event(
action="identity.employee_invited",
actor=context.actor_user,
organization=organization,
object_type="OrganizationInvitation",
object_id=str(issued.invitation.id),
payload={"role": role},
request=request,
)
enqueue_event(
DomainEvent(
aggregate_type="OrganizationInvitation",
aggregate_id=str(issued.invitation.id),
event_type=MEMBERSHIP_INVITATION_REQUESTED,
payload={"invitationId": issued.invitation.id},
tenant_context=context,
)
)
return issued.invitation
def invitation_preview(token: str) -> dict[str, object] | None:
"""Что видит человек по ссылке до входа: куда зовут и есть ли учётная запись."""
invitation = pending_invitation_for_token(token)
if invitation is None:
return None
return {
"email": invitation.email,
"organizationName": invitation.organization.name,
"accountExists": HumanUser.objects.filter(email__iexact=invitation.email).exists(),
}
@transaction.atomic
def register_and_accept(*, token: str, full_name: str, password: str) -> AcceptedInvitation:
"""Создать учётную запись по приглашению и сразу принять его.
Только для адреса без учётной записи: у существующей есть пароль, и
приглашение принимается после входа. Пароль проверяется теми же
правилами, что в мастере первого запуска.
"""
invitation = pending_invitation_for_token(token)
if invitation is None:
raise InvitationError(t("identity.invitation_invalid"), code="invitation_invalid")
if HumanUser.objects.filter(email__iexact=invitation.email).exists():
raise InvitationError(t("identity.invitation_account_exists"), code="account_exists")
name = " ".join(full_name.split())
if not name:
raise ValidationError({"fullName": t("setup.your_name_required")})
probe = HumanUser(email=invitation.email, full_name=name)
validate_password(password, user=probe)
user = HumanUser.objects.create_user(
email=invitation.email,
password=password,
full_name=name,
is_staff=False,
is_superuser=False,
must_change_password=False,
)
return accept_invitation(token=token, user=user)
def pending_invitation_for_token(token: str) -> OrganizationInvitation | None:
return _invitation_for_token(token, accepted=False)
def _invitation_for_token(token: str, *, accepted: bool) -> OrganizationInvitation | None:
"""Приглашение по токену из письма — без tenant-контекста на входе.
Ссылка /join приходит до входа в организацию, а таблица приглашений и
строка организации роли app без контекста не видны (tenancy/0003, 0033).
Организацию находит security-barrier каталог по хэшу токена (tenancy/0035),
и приглашение читается уже в её контексте — вместе с организацией, чтобы
вызывающий код мог обращаться к ней и после выхода из контекста.
"""
if not token:
return None
return OrganizationInvitation.objects.filter(
token_hash=_token_hash(token),
accepted_at__isnull=True,
token_hash = _token_hash(token)
route = invitation_route(token_hash)
if route is None:
return None
query = OrganizationInvitation.objects.select_related("organization").filter(
id=route.resource_id,
organization_id=route.organization_id,
token_hash=token_hash,
revoked_at__isnull=True,
expires_at__gt=timezone.now(),
).first()
)
if accepted:
query = query.filter(accepted_at__isnull=False)
else:
query = query.filter(accepted_at__isnull=True, expires_at__gt=timezone.now())
with tenant_atomic(route.organization_id):
return query.first()
@transaction.atomic
def accept_invitation(*, token: str, user: HumanUser) -> AcceptedInvitation:
"""Accept an OWNER invitation and activate the organization (SPEC-HUB-0021 §8.2).
"""Принять приглашение: владельца (SPEC-HUB-0021 §8.2) или сотрудника.
Idempotent: re-accepting the same token does not create a second membership
or usage period. Requires an authenticated HumanUser with a matching email.
Членство собирается из полей приглашения; для владельца организация ещё
и активируется. Идемпотентно: повторное принятие того же токена не создаёт
второго членства. Нужна учётная запись с тем же e-mail.
"""
invitation = pending_invitation_for_token(token)
if invitation is None:
@@ -110,13 +262,17 @@ def accept_invitation(*, token: str, user: HumanUser) -> AcceptedInvitation:
organization = invitation.organization
with tenant_atomic(organization.id):
membership = _ensure_owner_membership(organization, user)
membership = _ensure_membership(organization, user, invitation)
if invitation.role == EmployeeRole.OWNER:
_activate_organization(organization, user, membership)
invitation.accepted_at = timezone.now()
invitation.save(update_fields=["accepted_at"])
record_audit_event(
action="organization.owner_activated",
action=(
"organization.owner_activated"
if invitation.role == EmployeeRole.OWNER
else "identity.invitation_accepted"
),
actor=user,
organization=organization,
object_type="OrganizationInvitation",
@@ -128,25 +284,42 @@ def accept_invitation(*, token: str, user: HumanUser) -> AcceptedInvitation:
)
def _ensure_owner_membership(
organization: Organization, user: HumanUser
def _ensure_membership(
organization: Organization, user: HumanUser, invitation: OrganizationInvitation
) -> OrganizationMembership:
membership, _ = OrganizationMembership.objects.get_or_create(
position_title = invitation.position_title
if not position_title and invitation.role == EmployeeRole.OWNER:
position_title = t("setup.owner_position", language=customer_language(organization))
membership, created = OrganizationMembership.objects.get_or_create(
user=user,
organization=organization,
defaults={
"role": EmployeeRole.OWNER,
"position_title": "Владелец",
"role": invitation.role,
"position_title": position_title,
"phone": invitation.phone,
"totp_required": False,
},
)
if membership.role != EmployeeRole.OWNER:
# An existing non-OWNER membership for this user should not be silently
# promoted by an invitation; surface as a conflict instead.
if membership.role != invitation.role:
# Уже существующее членство с другой ролью приглашение молча не меняет:
# это конфликт, а не повышение.
raise InvitationError(
t("identity.role_conflict"),
code="role_conflict",
)
if created and invitation.group_ids:
# Группы, которые к моменту принятия ещё существуют.
groups = EmployeeGroup.objects.filter(
organization=organization, id__in=list(invitation.group_ids)
)
EmployeeGroupMember.objects.bulk_create(
[
EmployeeGroupMember(
organization_id=organization.id, group=group, employee=membership
)
for group in groups
]
)
return membership
@@ -165,16 +338,13 @@ def _already_accepted_for(
) -> AcceptedInvitation | None:
"""Idempotent re-accept: if this token was already accepted by the same user,
return the existing result instead of raising (SPEC-HUB-0021 §11/§15)."""
invitation = OrganizationInvitation.objects.filter(
token_hash=_token_hash(token),
accepted_at__isnull=False,
revoked_at__isnull=True,
).first()
invitation = _invitation_for_token(token, accepted=True)
if invitation is None:
return None
membership = OrganizationMembership.objects.filter(
user=user, organization=invitation.organization
).first()
with tenant_atomic(invitation.organization_id):
membership = OrganizationMembership.objects.filter(
user=user, organization=invitation.organization
).first()
if membership is None:
return None
return AcceptedInvitation(
@@ -0,0 +1,72 @@
"""Проверка SVG-логотипа организации.
SVG — это XML с возможностью исполнять скрипты и тянуть внешние ресурсы.
Логотип отдаётся с адреса самого приложения, поэтому опасный файл принимать
нельзя даже с защитными заголовками при отдаче: файл проверяется при загрузке
и отклоняется целиком, а не «чистится» — переписывать чужую графику молча
хуже, чем попросить другой файл.
Отклоняется: DOCTYPE и сущности, элементы script/foreignObject/iframe/
embed/object/audio/video, атрибуты-обработчики on*, ссылки javascript: и
data:text, внешние адреса в href/xlink:href и в url() внутри стилей.
"""
from __future__ import annotations
import re
import xml.etree.ElementTree as ET
SVG_CONTENT_TYPE = "image/svg+xml"
_FORBIDDEN_TAGS = frozenset({"script", "foreignobject", "iframe", "embed", "object", "audio", "video"})
_HREF_ATTRIBUTES = frozenset({"href", "{http://www.w3.org/1999/xlink}href"})
_DECLARATION = re.compile(rb"<!\s*(DOCTYPE|ENTITY)", re.IGNORECASE)
_EXTERNAL_URL = re.compile(r"url\(\s*['\"]?\s*(?!#|data:image/)", re.IGNORECASE)
def looks_like_svg(data: bytes) -> bool:
head = data.lstrip(b"\xef\xbb\xbf \t\r\n")[:4096].lower()
if head.startswith(b"<svg"):
return True
return head.startswith((b"<?xml", b"<!--")) and b"<svg" in head
def _local(name: str) -> str:
return name.rsplit("}", 1)[-1].lower()
def _dangerous_value(value: str) -> bool:
compact = re.sub(r"\s+", "", value).lower()
return compact.startswith("javascript:") or compact.startswith("data:text") or compact.startswith("vbscript:")
def svg_is_safe(data: bytes) -> bool:
if _DECLARATION.search(data):
return False
try:
root = ET.fromstring(data)
except ET.ParseError:
return False
if _local(root.tag) != "svg":
return False
for element in root.iter():
tag = _local(element.tag) if isinstance(element.tag, str) else ""
if tag in _FORBIDDEN_TAGS:
return False
for name, value in element.attrib.items():
local = _local(name)
if local.startswith("on"):
return False
if _dangerous_value(value):
return False
if name in _HREF_ATTRIBUTES or local == "href":
stripped = value.strip()
if stripped and not (stripped.startswith("#") or stripped.lower().startswith("data:image/")):
return False
if local == "style" and _EXTERNAL_URL.search(value):
return False
if tag == "style" and element.text and (
"@import" in element.text.lower() or _EXTERNAL_URL.search(element.text)
):
return False
return True
@@ -11,9 +11,9 @@ from django.core.management.base import BaseCommand, CommandError
from chatballs.identity.demo_models import DemoDataset, DemoDatasetStatus
from chatballs.identity.demo_seed import service
from chatballs.identity.models import Organization
from chatballs.tenancy.context import TenantActorKind, TenantContext
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.lookup import organization_by_slug
class Command(BaseCommand):
@@ -26,10 +26,9 @@ class Command(BaseCommand):
group.add_argument("--remove", action="store_true", help="Remove the installed demo dataset")
def handle(self, *args: object, **options: object) -> None:
try:
organization = Organization.objects.get(slug=options["organization"])
except Organization.DoesNotExist as error:
raise CommandError(f"Organization {options['organization']!r} not found") from error
organization = organization_by_slug(str(options["organization"]))
if organization is None:
raise CommandError(f"Organization {options['organization']!r} not found")
context = TenantContext.for_resource(organization, actor_kind=TenantActorKind.SYSTEM)
if not options["apply"] and not options["remove"]:
@@ -0,0 +1,37 @@
from __future__ import annotations
from django.core.management.base import BaseCommand, CommandError
from chatballs.identity.models import HumanUser
class Command(BaseCommand):
help = (
"Grants or revokes the installation administrator flag for an existing "
"account. The flag controls installation-wide settings (address, mail, "
"TURN, file storage) and is not derived from any organization role. "
"Passwords and memberships are not touched."
)
def add_arguments(self, parser) -> None:
parser.add_argument("--email", required=True, help="Account e-mail")
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--grant", action="store_true", help="Make the account an installation administrator")
group.add_argument("--revoke", action="store_true", help="Remove the installation administrator flag")
def handle(self, *args: object, **options: object) -> None:
email = HumanUser.objects.normalize_email(str(options["email"])).lower()
user = HumanUser.objects.filter(email__iexact=email).first()
if user is None:
raise CommandError(f"Account {email!r} not found")
grant = bool(options["grant"])
if grant and not user.is_active:
raise CommandError(f"Account {email!r} is inactive")
if not grant and not HumanUser.objects.filter(is_instance_admin=True, is_active=True).exclude(pk=user.pk).exists():
raise CommandError("Cannot revoke the last active installation administrator")
if user.is_instance_admin == grant:
self.stdout.write(f"{email}: already {'an' if grant else 'not an'} installation administrator")
return
user.is_instance_admin = grant
user.save(update_fields=["is_instance_admin"])
self.stdout.write(self.style.SUCCESS(f"{email}: installation administrator {'granted' if grant else 'revoked'}"))
@@ -0,0 +1,28 @@
# Администратор установки — глобальный признак учётной записи, а не роль в
# организации: настройки инсталляции (адрес, почта, TURN, хранилище) общие для
# всех организаций, и менять их вправе не любой владелец организации.
#
# Существующие установки: признак получают те, кому мастер первого запуска
# (или bootstrap локального контура) выдал is_superuser — это и есть владелец
# установки. Провижининг через платформенный API суперпользователей не создаёт.
from django.db import migrations, models
def grant_to_setup_owners(apps, schema_editor):
HumanUser = apps.get_model("identity", "HumanUser")
HumanUser.objects.filter(is_superuser=True).update(is_instance_admin=True)
class Migration(migrations.Migration):
dependencies = [
("identity", "0035_language_settings"),
]
operations = [
migrations.AddField(
model_name="humanuser",
name="is_instance_admin",
field=models.BooleanField(default=False),
),
migrations.RunPython(grant_to_setup_owners, migrations.RunPython.noop),
]
@@ -0,0 +1,29 @@
# Приглашение существующего пользователя во вторую организацию: вместо ошибки
# «e-mail занят» администратор выписывает приглашение с той же ролью,
# должностью, телефоном и группами, что и при создании сотрудника. Членство
# создаётся из этих полей в момент принятия, а не в момент приглашения.
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("identity", "0036_humanuser_is_instance_admin"),
]
operations = [
migrations.AddField(
model_name="organizationinvitation",
name="position_title",
field=models.CharField(blank=True, default="", max_length=120),
),
migrations.AddField(
model_name="organizationinvitation",
name="phone",
field=models.CharField(blank=True, default="", max_length=32),
),
migrations.AddField(
model_name="organizationinvitation",
name="group_ids",
field=models.JSONField(blank=True, default=list),
),
]
@@ -84,6 +84,11 @@ class HumanUser(AbstractUser):
# русский, а отсутствие личного выбора, и она переживает смену языка
# организации, тогда как записанный при регистрации код — нет.
ui_language = models.CharField(max_length=5, blank=True, default="")
# Администратор установки: право менять свойства инсталляции — адрес,
# почту, TURN, хранилище файлов. Это не роль в организации, а признак
# учётной записи: первым его получает владелец из мастера первого
# запуска, дальше его передают командой set_instance_admin.
is_instance_admin = models.BooleanField(default=False)
# Фото сотрудника (дизайн-базлайн v2): видно коллегам в сайдбаре, подписи
# сообщений, выборе ответственного. Загружается в профиле.
avatar = models.FileField(upload_to=user_avatar_upload_path, storage=user_storage, max_length=512, blank=True, default="")
@@ -0,0 +1,143 @@
"""Создание организации человеком из интерфейса.
Кнопка «Добавить организацию» в переключателе (дизайн-базлайн v2, A1) ведёт
на страницу с полями организации; тот, кто её заполнил, становится владельцем
новой организации и сразу в неё переключается. Это второй путь появления
организации рядом с платформенным провижинингом (platform.provisioning_service):
там оператор заводит организацию для чужого владельца по e-mail, здесь человек
заводит её себе.
Кто может: администратор установки и любой, у кого есть роль владельца или
администратора хотя бы в одной организации. Сотрудник, работающий только в
чате, чужую установку организациями не засевает.
"""
from __future__ import annotations
from dataclasses import dataclass
from django.db import transaction
from django.utils.text import slugify
from chatballs.ai.knowledge_categories import ensure_uncategorized_category
from chatballs.events.services import DomainEvent, enqueue_event
from chatballs.i18n import t
from chatballs.i18n.audience import customer_language
from chatballs.identity.administration_services import (
OrganizationSettingsInput,
validate_organization_settings,
)
from chatballs.identity.audit import record_audit_event
from chatballs.identity.models import (
EmployeeRole,
HumanUser,
Organization,
OrganizationMembership,
OrganizationStatus,
)
from chatballs.tenancy.context import TenantActorKind, TenantContext
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.ingress import membership_routes_for_user
from chatballs.tenancy.lookup import organization_route_by_slug, reserve_organization_id
MANAGER_ROLES = frozenset({EmployeeRole.OWNER, EmployeeRole.ADMIN})
@dataclass(frozen=True, slots=True)
class CreatedOrganization:
organization: Organization
membership: OrganizationMembership
def can_create_organization(user: HumanUser) -> bool:
"""Администратор установки или менеджер (владелец/администратор) где-либо."""
if not user.is_active:
return False
if user.is_instance_admin:
return True
for route in membership_routes_for_user(user.id):
with tenant_atomic(route.organization_id):
role = (
OrganizationMembership.objects.filter(
id=route.resource_id, user=user, blocked_at__isnull=True
)
.values_list("role", flat=True)
.first()
)
if role in MANAGER_ROLES:
return True
return False
def unique_organization_slug(name: str) -> str:
"""Слаг из имени, уникальный среди организаций установки.
Проверка идёт через каталог организаций: роль app без контекста строк
организаций не видит (tenancy/0033).
"""
base = slugify(name)[:40].strip("-") or "organization"
candidate = base
suffix = 2
while organization_route_by_slug(candidate) is not None:
candidate = f"{base}-{suffix}"
suffix += 1
return candidate
def create_organization(
*, data: OrganizationSettingsInput, owner: HumanUser
) -> CreatedOrganization:
"""Создать организацию и сделать человека её владельцем — одной транзакцией.
Порядок тот же, что у мастера первого запуска (identity.setup): id
выделяется заранее, строка вставляется уже в контексте этого id — иначе
роль app не увидит собственную вставку (tenancy/0033, политика 0035).
"""
clean = validate_organization_settings(data)
with transaction.atomic():
organization_id = reserve_organization_id()
with tenant_atomic(organization_id):
organization = Organization(
id=organization_id,
name=clean.name,
slug=unique_organization_slug(clean.name),
status=OrganizationStatus.ACTIVE,
timezone=clean.timezone,
currency=clean.currency,
language=clean.language,
)
organization.save(force_insert=True)
ensure_uncategorized_category(organization)
membership = OrganizationMembership.objects.create(
user=owner,
organization=organization,
role=EmployeeRole.OWNER,
# Должность — текстом на языке организации, как в провижининге.
position_title=t("setup.owner_position", language=customer_language(organization)),
totp_required=False,
)
record_audit_event(
action="organization.created",
actor=owner,
organization=organization,
object_type="Organization",
object_id=str(organization.public_id),
payload={"organizationName": organization.name},
)
enqueue_event(
DomainEvent(
aggregate_type="Organization",
aggregate_id=str(organization.public_id),
event_type="organization.provisioned",
payload={},
tenant_context=TenantContext.for_resource(
organization,
actor_kind=TenantActorKind.SYSTEM,
actor_user=owner,
),
)
)
return CreatedOrganization(organization=organization, membership=membership)
@@ -0,0 +1,12 @@
from django.urls import path
from chatballs.identity import organization_views
urlpatterns = [
path("", organization_views.OrganizationCreateView.as_view(), name="organization-create"),
path(
"options/",
organization_views.OrganizationCreateOptionsView.as_view(),
name="organization-create-options",
),
]
@@ -0,0 +1,76 @@
"""Создание организации из интерфейса: /api/v1/organizations/ без uuid в адресе.
Организации ещё нет, поэтому tenant middleware этот путь не трогает: контекст
открывает сам сервис вокруг вставки. Ответ повторяет форму ответа приглашения
(auth.invitations): обновлённая учётная запись со списком членств и публичный
id организации, в которую интерфейсу переключиться.
"""
from __future__ import annotations
from django.core.exceptions import ValidationError
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from chatballs.i18n import t
from chatballs.identity.administration_payloads import (
administration_languages,
administration_timezones,
)
from chatballs.identity.administration_services import OrganizationSettingsInput
from chatballs.identity.auth.common import _user_payload, validation_response
from chatballs.identity.organization_creation import (
can_create_organization,
create_organization,
)
def _forbidden() -> Response:
return Response({"detail": t("identity.organization_create_forbidden")}, status=403)
class OrganizationCreateOptionsView(APIView):
"""Справочники для формы: часовые пояса и языки, как в «Настройках»."""
permission_classes = [IsAuthenticated]
def get(self, request: Request) -> Response:
if not can_create_organization(request.user):
return _forbidden()
return Response(
{
"timezones": administration_timezones(),
"languages": administration_languages(),
"currencies": ["RUB"],
}
)
class OrganizationCreateView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request: Request) -> Response:
if not can_create_organization(request.user):
return _forbidden()
body = request.data if isinstance(request.data, dict) else {}
try:
created = create_organization(
data=OrganizationSettingsInput(
name=str(body.get("name", "")),
timezone=str(body.get("timezone", "") or "Europe/Moscow"),
currency=str(body.get("currency", "") or "RUB"),
language=str(body.get("language", "")),
),
owner=request.user,
)
except ValidationError as error:
return validation_response(error)
return Response(
{
"user": _user_payload(request.user),
"organizationPublicId": str(created.organization.public_id),
},
status=201,
)
+27 -21
View File
@@ -5,16 +5,16 @@
Никаких параметров в .env и CLI: всё задаёт человек в браузере. После
создания владельца мастер закрывается навсегда (409).
Запись идёт на соединении ``platform`` — единственной runtime-роли с правом
создавать организации (SPEC-HUB-0021 §10); RLS-контекст и транзакция живут
на том же соединении.
Запись идёт по основному соединению процесса. Роль app вправе вставить
организацию только пока их нет (политика tenancy/0032); дальше создавать
организации может только роль platform (SPEC-HUB-0021 §10). Так пароль
platform-роли не нужен процессу backend-app.
"""
from __future__ import annotations
from dataclasses import dataclass
from django.conf import settings
from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
@@ -35,14 +35,15 @@ from chatballs.identity.models import (
)
from chatballs.tenancy.context import TenantActorKind, TenantContext
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.routing import use_database
from chatballs.tenancy.ingress import organization_route_by_slug
from chatballs.tenancy.lookup import instance_has_organizations, reserve_organization_id
ORGANIZATION_NAME_MAX_LENGTH = 255
FULL_NAME_MAX_LENGTH = 255
# Алиас соединения для операций уровня инстанса. В тестах оба алиаса —
# зеркала одной тестовой БД под ролью-владельцем кластера.
INSTANCE_DB_ALIAS = "default" if settings.TESTING else "platform"
# Мастер работает по основному соединению процесса: отдельный алиас с ролью
# platform ему больше не нужен.
INSTANCE_DB_ALIAS = "default"
class SetupAlreadyCompleted(Exception):
@@ -70,11 +71,10 @@ class SetupResult:
def instance_needs_setup() -> bool:
"""Мастер нужен, пока не создана ни одна организация.
Организации видны роли app целиком (RLS SELECT USING true), поэтому
проверка не требует tenant-контекста.
Строки организаций роли app без контекста не видны (tenancy/0033):
наличие хотя бы одной проверяет SECURITY DEFINER-функция.
"""
with use_database(INSTANCE_DB_ALIAS):
return not Organization.objects.exists()
return not instance_has_organizations()
def _clean(data: SetupInput) -> SetupInput:
@@ -110,7 +110,7 @@ def _unique_slug(name: str) -> str:
base = slugify(name)[:40].strip("-") or "organization"
candidate = base
suffix = 2
while Organization.objects.filter(slug=candidate).exists():
while organization_route_by_slug(candidate) is not None:
candidate = f"{base}-{suffix}"
suffix += 1
return candidate
@@ -138,7 +138,7 @@ def _complete_setup(
data: SetupInput, language: str, public_host: str, public_scheme: str
) -> SetupResult:
clean = _clean(data)
with use_database(INSTANCE_DB_ALIAS), transaction.atomic(using=INSTANCE_DB_ALIAS):
with transaction.atomic(using=INSTANCE_DB_ALIAS):
# Адрес, на котором человек прошёл мастер, и есть публичный адрес
# установки: другого источника у коробки нет.
if public_host:
@@ -148,7 +148,7 @@ def _complete_setup(
# увидит созданную организацию.
with connections[INSTANCE_DB_ALIAS].cursor() as cursor:
cursor.execute("SELECT pg_advisory_xact_lock(hashtext('chatballs.instance_setup'))")
if Organization.objects.exists():
if instance_has_organizations(using=INSTANCE_DB_ALIAS):
raise SetupAlreadyCompleted()
# Пароль проверяется против атрибутов будущего пользователя (схожесть
@@ -156,12 +156,17 @@ def _complete_setup(
probe = HumanUser(email=clean.email, full_name=clean.full_name)
validate_password(clean.password, user=probe)
organization = Organization.objects.create(
name=clean.organization_name,
slug=_unique_slug(clean.organization_name),
status=OrganizationStatus.ACTIVE,
)
with tenant_atomic(organization.id, using=INSTANCE_DB_ALIAS):
# id выделяется заранее: строка организации видна роли app только в
# её контексте, и вставка идёт уже внутри него (tenancy/0033).
organization_id = reserve_organization_id(using=INSTANCE_DB_ALIAS)
with tenant_atomic(organization_id, using=INSTANCE_DB_ALIAS):
organization = Organization(
id=organization_id,
name=clean.organization_name,
slug=_unique_slug(clean.organization_name),
status=OrganizationStatus.ACTIVE,
)
organization.save(force_insert=True)
ensure_uncategorized_category(organization)
owner = HumanUser.objects.create_user(
email=clean.email,
@@ -169,6 +174,7 @@ def _complete_setup(
full_name=clean.full_name,
is_staff=True,
is_superuser=True,
is_instance_admin=True,
)
OrganizationMembership.objects.create(
user=owner,
+2 -18
View File
@@ -9,7 +9,7 @@ from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView
from chatballs.i18n import t
from chatballs.identity.auth.common import _user_payload
from chatballs.identity.auth.common import _user_payload, validation_response
from chatballs.identity.setup import (
SetupAlreadyCompleted,
SetupInput,
@@ -23,22 +23,6 @@ def setup_closed() -> dict[str, str]:
return {"detail": t("identity.setup_already_done")}
def _validation_response(error: ValidationError) -> Response:
if hasattr(error, "message_dict"):
errors = {
key: messages[0] if isinstance(messages, list) else str(messages)
for key, messages in error.message_dict.items()
}
# validate_password кладёт сообщения без ключа поля.
if "__all__" in errors:
errors["password"] = " ".join(error.message_dict["__all__"])
del errors["__all__"]
detail = next(iter(errors.values()), t("setup.check_fields"))
return Response({"detail": detail, "errors": errors}, status=400)
message = " ".join(error.messages)
return Response({"detail": message, "errors": {"password": message}}, status=400)
@method_decorator(ensure_csrf_cookie, name="dispatch")
class SetupStatusView(APIView):
authentication_classes: list = []
@@ -75,7 +59,7 @@ class SetupView(APIView):
except SetupAlreadyCompleted:
return Response(setup_closed(), status=409)
except ValidationError as error:
return _validation_response(error)
return validation_response(error)
owner = result.owner
owner.backend = "django.contrib.auth.backends.ModelBackend"
login(request, owner)
@@ -172,16 +172,16 @@ class AdministrationApiTests(TestCase):
"/api/v1/company/administration/logo/",
{
"file": SimpleUploadedFile(
"logo.svg",
b"<svg></svg>",
content_type="image/svg+xml",
"logo.txt",
b"not an image at all",
content_type="text/plain",
)
},
format="multipart",
)
self.assertEqual(response.status_code, 400)
self.assertIn("PNG, JPEG и WebP", response.json()["detail"])
self.assertIn("PNG, JPEG, WebP и SVG", response.json()["detail"])
def test_audit_returns_readable_label_next_to_the_action_code(self) -> None:
record_audit_event(
@@ -370,7 +370,7 @@ class InstanceAddressTests(TestCase):
def test_owner_sets_domain_and_scheme(self) -> None:
response = self.client.patch(
"/api/v1/company/administration/instance/",
"/api/v1/instance/settings/",
{"publicHost": "crm.example.com", "publicScheme": "https"},
format="json",
)
@@ -383,7 +383,7 @@ class InstanceAddressTests(TestCase):
def test_address_may_be_a_bare_ip(self) -> None:
# Коробку часто так и оставляют: сервер по IP, без домена.
response = self.client.patch(
"/api/v1/company/administration/instance/",
"/api/v1/instance/settings/",
{"publicHost": "203.0.113.10", "publicScheme": "http"},
format="json",
)
@@ -393,7 +393,7 @@ class InstanceAddressTests(TestCase):
def test_url_is_accepted_and_trimmed_to_host(self) -> None:
response = self.client.patch(
"/api/v1/company/administration/instance/",
"/api/v1/instance/settings/",
{"publicHost": "https://crm.example.com/settings", "publicScheme": "https"},
format="json",
)
@@ -403,7 +403,7 @@ class InstanceAddressTests(TestCase):
def test_garbage_is_rejected(self) -> None:
response = self.client.patch(
"/api/v1/company/administration/instance/",
"/api/v1/instance/settings/",
{"publicHost": "не адрес!", "publicScheme": "ftp"},
format="json",
)
@@ -421,7 +421,7 @@ class InstanceEmailTests(TestCase):
def patch(self, **email):
return self.client.patch(
"/api/v1/company/administration/instance/",
"/api/v1/instance/settings/",
{"publicHost": "crm.example.com", "publicScheme": "https", "email": email},
format="json",
)
@@ -453,7 +453,7 @@ class InstanceEmailTests(TestCase):
def test_check_without_smtp_explains_itself(self) -> None:
response = self.client.post(
"/api/v1/company/administration/instance/email-check/", {}, format="json"
"/api/v1/instance/settings/email-check/", {}, format="json"
)
self.assertEqual(response.status_code, 400)
@@ -0,0 +1,215 @@
"""Приглашение существующей учётной записи во вторую организацию.
Учётная запись глобальная, членство — по согласию человека: создание
сотрудника с уже занятым e-mail выписывает приглашение, письмо уходит из
воркера с новым токеном, а членство появляется после принятия.
"""
from __future__ import annotations
from django.core import mail
from django.test import TestCase
from chatballs.events.handlers import dispatch
from chatballs.events.models import OutboxEvent
from chatballs.identity.group_models import EmployeeGroup
from chatballs.identity.invitation_models import OrganizationInvitation
from chatballs.identity.invitation_service import MEMBERSHIP_INVITATION_REQUESTED
from chatballs.identity.models import (
EmployeeRole,
HumanUser,
Organization,
OrganizationMembership,
)
from chatballs.testing import TenantAPIClient
PASSWORD = "Long-and-strong-passphrase-42"
class InvitationTestBase(TestCase):
def setUp(self) -> None:
self.first = Organization.objects.create(name="First", slug="inv-first")
self.second = Organization.objects.create(name="Second", slug="inv-second")
self.second_owner = HumanUser.objects.create_user(
email="second-owner@example.test", password=PASSWORD, full_name="Second Owner"
)
self.second_membership = OrganizationMembership.objects.create(
organization=self.second,
user=self.second_owner,
role=EmployeeRole.OWNER,
position_title="Owner",
)
self.group = EmployeeGroup.objects.create(organization=self.second, name="Support")
# Человек уже работает в первой организации.
self.person = HumanUser.objects.create_user(
email="person@example.test", password=PASSWORD, full_name="Person"
)
OrganizationMembership.objects.create(
organization=self.first,
user=self.person,
role=EmployeeRole.EMPLOYEE,
position_title="Operator",
)
self.client = TenantAPIClient()
self.client.force_authenticate(self.second_owner)
self.client.set_tenant(self.second)
def _create(self, email: str = "person@example.test"):
return self.client.post(
"/api/v1/employees/operators/",
{
"email": email,
"fullName": "Person",
"positionTitle": "Support operator",
"role": EmployeeRole.EMPLOYEE,
"passwordMode": "mail",
"groupIds": [self.group.id],
},
format="json",
)
class InviteExistingUserTests(InvitationTestBase):
def test_existing_account_gets_an_invitation_instead_of_an_error(self) -> None:
response = self._create()
self.assertEqual(response.status_code, 201, response.content)
self.assertTrue(response.json()["invited"])
self.assertIsNone(response.json()["employee"])
self.assertFalse(
OrganizationMembership.objects.filter(user=self.person, organization=self.second).exists()
)
invitation = OrganizationInvitation.objects.get(organization=self.second, email="person@example.test")
self.assertEqual(invitation.role, EmployeeRole.EMPLOYEE)
self.assertEqual(invitation.position_title, "Support operator")
self.assertEqual(invitation.group_ids, [self.group.id])
self.assertTrue(
OutboxEvent.objects.filter(
event_type=MEMBERSHIP_INVITATION_REQUESTED, organization=self.second
).exists()
)
def test_same_organization_still_reports_the_address_as_taken(self) -> None:
response = self._create(email="second-owner@example.test")
self.assertEqual(response.status_code, 400)
self.assertFalse(OrganizationInvitation.objects.filter(organization=self.second).exists())
def test_worker_sends_the_letter_and_the_link_accepts(self) -> None:
self._create()
event = OutboxEvent.objects.get(event_type=MEMBERSHIP_INVITATION_REQUESTED)
dispatch(event)
self.assertEqual(len(mail.outbox), 1)
letter = mail.outbox[0]
self.assertEqual(letter.to, ["person@example.test"])
self.assertIn("/join?token=", letter.body)
token = letter.body.split("/join?token=", 1)[1].split()[0]
person_client = TenantAPIClient()
person_client.force_authenticate(self.person)
accepted = person_client.post("/api/v1/auth/invitations/accept/", {"token": token}, format="json")
self.assertEqual(accepted.status_code, 200, accepted.content)
self.assertEqual(accepted.json()["organizationPublicId"], str(self.second.public_id))
membership = OrganizationMembership.objects.get(user=self.person, organization=self.second)
self.assertEqual(membership.role, EmployeeRole.EMPLOYEE)
self.assertEqual(membership.position_title, "Support operator")
self.assertEqual([link.group_id for link in membership.group_links.all()], [self.group.id])
self.assertEqual(
{item["organizationPublicId"] for item in accepted.json()["user"]["memberships"]},
{str(self.first.public_id), str(self.second.public_id)},
)
def test_reinvite_replaces_the_pending_invitation(self) -> None:
self._create()
first = OrganizationInvitation.objects.get(organization=self.second, email="person@example.test")
self._create()
first.refresh_from_db()
self.assertIsNotNone(first.revoked_at)
self.assertEqual(
OrganizationInvitation.objects.filter(
organization=self.second, email="person@example.test", revoked_at__isnull=True
).count(),
1,
)
def test_stranger_cannot_use_someone_elses_invitation(self) -> None:
self._create()
dispatch(OutboxEvent.objects.get(event_type=MEMBERSHIP_INVITATION_REQUESTED))
token = mail.outbox[0].body.split("/join?token=", 1)[1].split()[0]
stranger = HumanUser.objects.create_user(email="stranger@example.test", password=PASSWORD)
client = TenantAPIClient()
client.force_authenticate(stranger)
response = client.post("/api/v1/auth/invitations/accept/", {"token": token}, format="json")
self.assertEqual(response.status_code, 400)
self.assertFalse(
OrganizationMembership.objects.filter(user=stranger, organization=self.second).exists()
)
class PendingInvitationRowsTests(InvitationTestBase):
"""Ожидающие приглашения видны в списке сотрудников и управляются оттуда."""
def test_list_shows_the_invitation_with_role_position_and_groups(self) -> None:
self._create()
payload = self.client.get("/api/v1/employees/").json()
self.assertEqual(len(payload["invitations"]), 1)
row = payload["invitations"][0]
self.assertEqual(row["email"], "person@example.test")
self.assertEqual(row["fullName"], "Person")
self.assertEqual(row["role"], EmployeeRole.EMPLOYEE)
self.assertEqual(row["positionTitle"], "Support operator")
self.assertEqual([group["id"] for group in row["groups"]], [self.group.id])
# Фильтры списка действуют и на приглашения.
self.assertEqual(self.client.get("/api/v1/employees/?role=ADMIN").json()["invitations"], [])
self.assertEqual(len(self.client.get("/api/v1/employees/?q=person").json()["invitations"]), 1)
self.assertEqual(self.client.get("/api/v1/employees/?q=nobody").json()["invitations"], [])
def test_resend_extends_expiry_and_queues_a_new_letter(self) -> None:
self._create()
invitation = OrganizationInvitation.objects.get(organization=self.second, email="person@example.test")
OutboxEvent.objects.filter(event_type=MEMBERSHIP_INVITATION_REQUESTED).delete()
response = self.client.post(f"/api/v1/employees/invitations/{invitation.id}/resend/")
self.assertEqual(response.status_code, 200, response.content)
self.assertEqual(OutboxEvent.objects.filter(event_type=MEMBERSHIP_INVITATION_REQUESTED).count(), 1)
refreshed = OrganizationInvitation.objects.get(pk=invitation.id)
self.assertGreaterEqual(refreshed.expires_at, invitation.expires_at)
def test_revoke_hides_the_row_and_kills_the_link(self) -> None:
self._create()
dispatch(OutboxEvent.objects.get(event_type=MEMBERSHIP_INVITATION_REQUESTED))
token = mail.outbox[0].body.split("/join?token=", 1)[1].split()[0]
invitation = OrganizationInvitation.objects.get(organization=self.second, email="person@example.test")
response = self.client.post(f"/api/v1/employees/invitations/{invitation.id}/revoke/")
self.assertEqual(response.status_code, 200, response.content)
self.assertEqual(self.client.get("/api/v1/employees/").json()["invitations"], [])
person_client = TenantAPIClient()
person_client.force_authenticate(self.person)
accepted = person_client.post("/api/v1/auth/invitations/accept/", {"token": token}, format="json")
self.assertEqual(accepted.status_code, 400)
def test_employee_cannot_manage_invitations(self) -> None:
self._create()
invitation = OrganizationInvitation.objects.get(organization=self.second, email="person@example.test")
operator = HumanUser.objects.create_user(email="operator@example.test", password=PASSWORD)
OrganizationMembership.objects.create(
organization=self.second, user=operator, role=EmployeeRole.EMPLOYEE, position_title="Operator"
)
client = TenantAPIClient()
client.force_authenticate(operator)
client.set_tenant(self.second)
self.assertEqual(client.post(f"/api/v1/employees/invitations/{invitation.id}/revoke/").status_code, 403)
self.assertEqual(client.post(f"/api/v1/employees/invitations/{invitation.id}/resend/").status_code, 403)
@@ -12,15 +12,19 @@
from __future__ import annotations
import time
from django.core.exceptions import ValidationError
from django.test import TestCase
from chatballs.identity import instance_settings
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.instance_settings import (
InstanceSettings,
accepted_hosts,
invalidate_cache,
)
from chatballs.identity.models import EmployeeRole, HumanUser, Organization, OrganizationMembership
from chatballs.support_portals.models import SupportPortal
from chatballs.testing import TenantAPIClient
@@ -42,7 +46,7 @@ class InstanceAddressChangeTests(TestCase):
def _patch(self, host: str, scheme: str = "https"):
return self.client.patch(
"/api/v1/company/administration/instance/",
"/api/v1/instance/settings/",
{"publicHost": host, "publicScheme": scheme},
format="json",
)
@@ -119,3 +123,132 @@ class PortalDomainCollisionTests(TestCase):
portal.clean() # не должно бросать
self.assertEqual(portal.custom_domain, "help.example.test")
class InstanceSettingsAccessTests(TestCase):
"""Настройки установки меняет только её администратор.
Владелец другой организации видит адреса TURN (карточка relay), но не может
переключить общий SMTP или адрес установки; сотрудник не видит ничего.
"""
def setUp(self) -> None:
self.result = bootstrap_owner(email="instance-owner@example.com", password=PASSWORD)
self.other_org = Organization.objects.create(name="Other", slug="other-org")
self.other_owner = HumanUser.objects.create_user(
email="other-owner@example.com", password=PASSWORD, full_name="Other Owner"
)
OrganizationMembership.objects.create(
organization=self.other_org,
user=self.other_owner,
role=EmployeeRole.OWNER,
position_title="Owner",
)
self.employee = HumanUser.objects.create_user(
email="employee@example.com", password=PASSWORD, full_name="Employee"
)
OrganizationMembership.objects.create(
organization=self.other_org,
user=self.employee,
role=EmployeeRole.EMPLOYEE,
position_title="Operator",
)
def _client(self, user: HumanUser) -> TenantAPIClient:
client = TenantAPIClient()
client.force_authenticate(user)
return client
def test_setup_owner_is_instance_admin_and_may_change_settings(self) -> None:
self.assertTrue(self.result.owner.is_instance_admin)
response = self._client(self.result.owner).patch(
"/api/v1/instance/settings/",
{"publicHost": "crm.example.test", "publicScheme": "https"},
format="json",
)
self.assertEqual(response.status_code, 200, response.content)
def test_organization_owner_reads_but_cannot_change(self) -> None:
client = self._client(self.other_owner)
self.assertEqual(client.get("/api/v1/instance/settings/").status_code, 200)
self.assertEqual(client.get("/api/v1/instance/storage/").status_code, 200)
denied = client.patch(
"/api/v1/instance/settings/",
{"publicHost": "crm.example.test", "publicScheme": "https"},
format="json",
)
self.assertEqual(denied.status_code, 403)
self.assertEqual(
client.post("/api/v1/instance/settings/email-check/", {}, format="json").status_code, 403
)
self.assertEqual(
client.patch("/api/v1/instance/storage/", {"backend": "LOCAL"}, format="json").status_code,
403,
)
def test_employee_sees_nothing(self) -> None:
client = self._client(self.employee)
self.assertEqual(client.get("/api/v1/instance/settings/").status_code, 403)
self.assertEqual(client.get("/api/v1/instance/storage/").status_code, 403)
def test_session_reports_the_flag(self) -> None:
admin_session = self._client(self.result.owner).get("/api/v1/auth/session/").json()
owner_session = self._client(self.other_owner).get("/api/v1/auth/session/").json()
self.assertTrue(admin_session["user"]["isInstanceAdmin"])
self.assertFalse(owner_session["user"]["isInstanceAdmin"])
def test_old_organization_scoped_paths_are_gone(self) -> None:
client = self._client(self.result.owner)
client.organization_public_id = str(self.result.organization.public_id)
response = client.get(
f"/api/v1/organizations/{self.result.organization.public_id}/company/administration/instance/"
)
self.assertEqual(response.status_code, 404)
class StaleHostCacheTests(TestCase):
"""Адрес, записанный мастером в одном процессе, принимает и соседний.
Кэш адреса живёт в каждом процессе gunicorn по 10 секунд. Соседний процесс
с устаревшим кэшем отвечал «Invalid host» на первый же запрос после
мастера — в интерфейсе это «Ошибка загрузки», исчезавшая после обновления
страницы. Промах по хосту теперь перечитывает кэш.
"""
def setUp(self) -> None:
self.result = bootstrap_owner(email="cache-owner@example.com", password=PASSWORD)
self.client = TenantAPIClient()
self.client.force_authenticate(self.result.owner)
# Строка настроек должна существовать: update() ниже её не создаёт.
InstanceSettings.load()
self.addCleanup(invalidate_cache)
def _stale_cache_with_no_host(self) -> None:
# Соседний процесс: только что прочитал пустой адрес, TTL ещё не вышел.
instance_settings._cached = (time.monotonic(), ("", ""))
instance_settings._last_miss_refresh = 0.0
def test_host_written_by_another_process_is_accepted_at_once(self) -> None:
self._stale_cache_with_no_host()
# Запись мимо save(): invalidate_cache() в этом процессе не вызывается,
# как и в реальности, где мастер отработал в другом воркере.
InstanceSettings.objects.filter(pk=InstanceSettings.SINGLETON_PK).update(
public_host="crm.example.test"
)
response = self.client.get("/api/v1/auth/session/", HTTP_HOST="crm.example.test")
self.assertEqual(response.status_code, 200, response.content)
def test_unknown_host_does_not_reread_more_than_once_a_second(self) -> None:
InstanceSettings.objects.filter(pk=InstanceSettings.SINGLETON_PK).update(
public_host="crm.example.test"
)
self._stale_cache_with_no_host()
self.assertEqual(self.client.get("/api/v1/auth/session/", HTTP_HOST="evil.example").status_code, 400)
# Первый промах перечитал кэш и уже знает настоящий адрес.
self.assertEqual(set(accepted_hosts()), {"crm.example.test"})
# Второй промах в ту же секунду базу не трогает: кэш подменён, но не перечитан.
instance_settings._cached = (time.monotonic(), ("", ""))
self.assertEqual(self.client.get("/api/v1/auth/session/", HTTP_HOST="evil.example").status_code, 400)
self.assertEqual(accepted_hosts(), ())
@@ -0,0 +1,77 @@
"""SVG-логотип организации: принимается чистый, отклоняется опасный.
Логотип отдаётся с адреса приложения, поэтому SVG проверяется при загрузке
(скрипты, обработчики, внешние ссылки), а при отдаче получает защитные
заголовки как второй рубеж.
"""
from __future__ import annotations
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.logo_svg import svg_is_safe
from chatballs.testing import TenantAPIClient
CLEAN_SVG = b"""<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 64 64">
<defs><linearGradient id="g"><stop offset="0" stop-color="#1677ff"/><stop offset="1" stop-color="#003eb3"/></linearGradient></defs>
<style>.mark { fill: url(#g); }</style>
<circle class="mark" cx="32" cy="32" r="30"/>
<use xlink:href="#mark"/>
</svg>
"""
class SvgSafetyTests(TestCase):
def test_clean_svg_is_accepted(self) -> None:
self.assertTrue(svg_is_safe(CLEAN_SVG))
def test_dangerous_svg_is_rejected(self) -> None:
samples = {
"script": b'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>',
"handler": b'<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"><rect/></svg>',
"javascript href": b'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><a xlink:href="javascript:alert(1)"><rect/></a></svg>',
"external image": b'<svg xmlns="http://www.w3.org/2000/svg"><image href="https://evil.example/t.png"/></svg>',
"foreignObject": b'<svg xmlns="http://www.w3.org/2000/svg"><foreignObject><div>x</div></foreignObject></svg>',
"external css": b'<svg xmlns="http://www.w3.org/2000/svg"><style>@import url(https://evil.example/a.css);</style></svg>',
"style url": b'<svg xmlns="http://www.w3.org/2000/svg"><rect style="fill:url(http://evil.example/x)"/></svg>',
"doctype entity": b'<?xml version="1.0"?><!DOCTYPE svg [<!ENTITY x "y">]><svg xmlns="http://www.w3.org/2000/svg"/>',
"not svg": b'<html><body>hi</body></html>',
"broken xml": b'<svg xmlns="http://www.w3.org/2000/svg"><rect></svg>',
}
for name, sample in samples.items():
with self.subTest(name):
self.assertFalse(svg_is_safe(sample))
class SvgLogoApiTests(TestCase):
def setUp(self) -> None:
result = bootstrap_owner(email="svg-owner@example.com", password="temporary-password")
self.client = TenantAPIClient()
self.client.force_authenticate(result.owner)
def _upload(self, data: bytes):
return self.client.post(
"/api/v1/company/administration/logo/",
{"file": SimpleUploadedFile("logo.svg", data, content_type="image/svg+xml")},
format="multipart",
)
def test_svg_logo_round_trip_with_protective_headers(self) -> None:
uploaded = self._upload(CLEAN_SVG)
self.assertEqual(uploaded.status_code, 200, uploaded.content)
downloaded = self.client.get("/api/v1/company/administration/logo/")
self.assertEqual(downloaded.status_code, 200)
self.assertEqual(downloaded["Content-Type"], "image/svg+xml")
self.assertIn("sandbox", downloaded["Content-Security-Policy"])
self.assertEqual(downloaded["X-Content-Type-Options"], "nosniff")
self.assertEqual(b"".join(downloaded.streaming_content), CLEAN_SVG)
def test_svg_with_script_is_refused_with_a_field_error(self) -> None:
response = self._upload(b'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>')
self.assertEqual(response.status_code, 400)
self.assertIn("file", response.json()["errors"])
@@ -0,0 +1,122 @@
"""Создание организации из интерфейса: кнопка «Добавить организацию» (A1)."""
from __future__ import annotations
import json
from django.test import TestCase, override_settings
from chatballs.ai.models import KnowledgeCategory
from chatballs.identity.models import (
AuditEvent,
EmployeeRole,
HumanUser,
Organization,
OrganizationMembership,
)
from chatballs.testing import TenantAPIClient
URL = "/api/v1/organizations/"
@override_settings(ROOT_URLCONF="chatballs_backend.urls_app")
class OrganizationCreationTests(TestCase):
def setUp(self) -> None:
self.first = Organization.objects.create(name="Ателье Норд", slug="atelie-nord")
self.owner = HumanUser.objects.create_user(
email="owner@example.test", password="Owner-pass-123!", full_name="Елена"
)
OrganizationMembership.objects.create(
organization=self.first,
user=self.owner,
role=EmployeeRole.OWNER,
position_title="Владелец",
)
self.employee = HumanUser.objects.create_user(
email="employee@example.test", password="Emp-pass-1234!", full_name="Иван"
)
OrganizationMembership.objects.create(
organization=self.first,
user=self.employee,
role=EmployeeRole.EMPLOYEE,
position_title="Оператор",
)
def _post(self, user: HumanUser, **body):
client = TenantAPIClient()
client.force_login(user)
return client.post(
URL,
data=json.dumps({"name": "Вторая компания", "timezone": "Europe/Moscow", **body}),
content_type="application/json",
)
def test_owner_creates_organization_and_becomes_its_owner(self) -> None:
response = self._post(self.owner, language="en")
self.assertEqual(response.status_code, 201, response.content)
payload = response.json()
created = Organization.objects.get(public_id=payload["organizationPublicId"])
self.assertEqual(created.name, "Вторая компания")
self.assertEqual(created.language, "en")
self.assertEqual(created.status, "ACTIVE")
membership = OrganizationMembership.objects.get(organization=created, user=self.owner)
self.assertEqual(membership.role, EmployeeRole.OWNER)
self.assertTrue(KnowledgeCategory.objects.filter(organization=created).exists())
# Список членств в ответе уже содержит новую организацию: интерфейсу
# есть куда переключиться без повторного запроса сессии.
self.assertEqual(
{item["organizationPublicId"] for item in payload["user"]["memberships"]},
{str(self.first.public_id), str(created.public_id)},
)
self.assertTrue(
AuditEvent.objects.filter(
organization=created, action="organization.created", actor=self.owner
).exists()
)
def test_same_name_gets_a_distinct_slug(self) -> None:
first = self._post(self.owner).json()["organizationPublicId"]
second = self._post(self.owner).json()["organizationPublicId"]
slugs = set(Organization.objects.filter(public_id__in=[first, second]).values_list("slug", flat=True))
self.assertEqual(len(slugs), 2)
def test_employee_cannot_create_organizations(self) -> None:
response = self._post(self.employee)
self.assertEqual(response.status_code, 403)
self.assertEqual(Organization.objects.count(), 1)
def test_instance_admin_without_memberships_can_create(self) -> None:
admin = HumanUser.objects.create_user(
email="admin@example.test", password="Admin-pass-123!", is_instance_admin=True
)
response = self._post(admin)
self.assertEqual(response.status_code, 201, response.content)
created = Organization.objects.get(public_id=response.json()["organizationPublicId"])
self.assertTrue(OrganizationMembership.objects.filter(organization=created, user=admin, role=EmployeeRole.OWNER).exists())
def test_empty_name_is_a_field_error(self) -> None:
response = self._post(self.owner, name=" ")
self.assertEqual(response.status_code, 400)
self.assertIn("name", response.json()["errors"])
self.assertEqual(Organization.objects.count(), 1)
def test_anonymous_is_rejected(self) -> None:
response = TenantAPIClient().post(URL, data=json.dumps({"name": "X"}), content_type="application/json")
self.assertIn(response.status_code, {401, 403})
def test_options_list_timezones_and_languages(self) -> None:
client = TenantAPIClient()
client.force_login(self.owner)
response = client.get(f"{URL}options/")
self.assertEqual(response.status_code, 200)
self.assertIn("Europe/Moscow", response.json()["timezones"])
self.assertTrue(response.json()["languages"])
@@ -0,0 +1,135 @@
"""Приглашение владельца из платформенного провижининга доходит до человека.
Учётной записи может ещё не быть: письмо уходит из воркера, а по ссылке
человек задаёт имя и пароль, принимает приглашение и оказывается владельцем
активированной организации. Существующая учётная запись идёт на обычный вход.
"""
from __future__ import annotations
from django.core import mail
from django.test import TestCase, override_settings
from chatballs.events.handlers import dispatch
from chatballs.events.models import OutboxEvent
from chatballs.identity.invitation_service import OWNER_INVITATION_REQUESTED
from chatballs.identity.models import (
EmployeeRole,
HumanUser,
OrganizationMembership,
OrganizationStatus,
)
from chatballs.platform.provisioning_command import ProvisioningCommand
from chatballs.platform.provisioning_service import provision_organization
from chatballs.platform.testing import create_platform_operator
from chatballs.testing import TenantAPIClient
PASSWORD = "Very-strong-passphrase-42"
@override_settings(ROOT_URLCONF="chatballs_backend.urls_app")
class OwnerInvitationFlowTests(TestCase):
def setUp(self) -> None:
self.operator, _ = create_platform_operator()
def _provision(self, email: str = "new-owner@example.test"):
return provision_organization(
command=ProvisioningCommand(
organization_name="Fresh Co",
organization_slug="fresh-co",
owner_email=email,
source="PLATFORM_OPERATOR",
idempotency_key=f"idem-{email}",
),
operator=self.operator,
)
def _token_from_mail(self) -> str:
return mail.outbox[-1].body.split("/join?token=", 1)[1].split()[0]
def test_letter_goes_out_and_a_new_person_registers_as_owner(self) -> None:
result = self._provision()
dispatch(OutboxEvent.objects.get(event_type=OWNER_INVITATION_REQUESTED))
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].to, ["new-owner@example.test"])
token = self._token_from_mail()
client = TenantAPIClient()
preview = client.get(f"/api/v1/auth/invitations/preview/?token={token}").json()
self.assertEqual(preview["valid"], True)
self.assertEqual(preview["organizationName"], "Fresh Co")
self.assertFalse(preview["accountExists"])
response = client.post(
"/api/v1/auth/invitations/register/",
{"token": token, "fullName": "New Owner", "password": PASSWORD},
format="json",
)
self.assertEqual(response.status_code, 201, response.content)
body = response.json()
self.assertEqual(body["organizationPublicId"], str(result.organization.public_id))
self.assertEqual(body["user"]["email"], "new-owner@example.test")
self.assertFalse(body["user"]["isInstanceAdmin"])
result.organization.refresh_from_db()
self.assertEqual(result.organization.status, OrganizationStatus.ACTIVE)
owner = HumanUser.objects.get(email="new-owner@example.test")
self.assertTrue(owner.check_password(PASSWORD))
self.assertFalse(owner.must_change_password)
self.assertTrue(
OrganizationMembership.objects.filter(
organization=result.organization, user=owner, role=EmployeeRole.OWNER
).exists()
)
# Сессия установлена: приложение сразу открывается под владельцем.
self.assertTrue(client.get("/api/v1/auth/session/").json()["authenticated"])
def test_weak_password_and_empty_name_are_reported_by_field(self) -> None:
self._provision()
dispatch(OutboxEvent.objects.get(event_type=OWNER_INVITATION_REQUESTED))
token = self._token_from_mail()
client = TenantAPIClient()
weak = client.post(
"/api/v1/auth/invitations/register/",
{"token": token, "fullName": "New Owner", "password": "short"},
format="json",
)
nameless = client.post(
"/api/v1/auth/invitations/register/",
{"token": token, "fullName": " ", "password": PASSWORD},
format="json",
)
self.assertEqual(weak.status_code, 400)
self.assertIn("password", weak.json()["errors"])
self.assertEqual(nameless.status_code, 400)
self.assertIn("fullName", nameless.json()["errors"])
self.assertFalse(HumanUser.objects.filter(email="new-owner@example.test").exists())
def test_existing_account_is_sent_to_login_and_cannot_register(self) -> None:
HumanUser.objects.create_user(email="known@example.test", password=PASSWORD, is_active=True)
# Активная учётная запись получает владение сразу, приглашение не нужно:
# проверяем ветку через приглашение сотрудника той же организации.
result = self._provision(email="known-owner@example.test")
dispatch(OutboxEvent.objects.get(event_type=OWNER_INVITATION_REQUESTED))
token = self._token_from_mail()
HumanUser.objects.create_user(email="known-owner@example.test", password=PASSWORD)
client = TenantAPIClient()
preview = client.get(f"/api/v1/auth/invitations/preview/?token={token}").json()
register = client.post(
"/api/v1/auth/invitations/register/",
{"token": token, "fullName": "Someone", "password": PASSWORD},
format="json",
)
self.assertTrue(preview["accountExists"])
self.assertEqual(register.status_code, 400)
self.assertEqual(register.json()["code"], "account_exists")
self.assertEqual(result.organization.status, OrganizationStatus.PENDING_OWNER)
def test_unknown_token_previews_as_invalid(self) -> None:
response = TenantAPIClient().get("/api/v1/auth/invitations/preview/?token=nope")
self.assertEqual(response.json(), {"valid": False})
@@ -50,6 +50,8 @@ COVERAGE_EXEMPT = {
# Настройки установки (адрес, по которому её открывают) — тоже одна
# строка на инстанс: их пишет мастер первого запуска, а не демо.
("identity", "instancesettings"),
# Состояние обновлений установки — одна строка на инстанс (updates/0001).
("updates", "updatestate"),
}
COVERAGE_EXEMPT_APPS = {"platform", "events"}
@@ -101,6 +101,9 @@ class SetupWizardTests(TestCase):
owner = HumanUser.objects.get(email="e.kuznetsova@atelie-nord.ru")
self.assertTrue(owner.check_password(VALID["password"]))
self.assertTrue(owner.is_superuser)
# Владелец из мастера — администратор установки: раздел «Платформа» его.
self.assertTrue(owner.is_instance_admin)
self.assertTrue(user["isInstanceAdmin"])
membership = OrganizationMembership.objects.get(user=owner, organization=organization)
self.assertEqual(membership.role, EmployeeRole.OWNER)
self.assertTrue(KnowledgeCategory.objects.filter(organization=organization).exists())
@@ -18,12 +18,12 @@ OpenAI-совместимый сервер или собственный Bot API
from __future__ import annotations
from chatballs.i18n import t
import ipaddress
import socket
from urllib.parse import urlsplit
from chatballs.i18n import t
HTTP_SCHEMES = frozenset({"http", "https"})
PROXY_SCHEMES = frozenset({"http", "https", "socks5", "socks5h"})
+1 -1
View File
@@ -5,9 +5,9 @@ from unittest import mock
from django.core.exceptions import ValidationError
from django.test import TestCase
from chatballs.i18n import tn
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.models import Organization
from chatballs.i18n import tn
from chatballs.integrations import checks
from chatballs.integrations.models import (
Integration,
@@ -12,12 +12,13 @@ import secrets
from datetime import timedelta
from django.db import transaction
from django.db.models import Q
from django.utils import timezone
from chatballs.conversations import transports
from chatballs.conversations.transports.base import InboundMessage
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
from chatballs.i18n import customer_language, first_chosen, normalize_language, t
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
from chatballs.notifications.models import MessengerBinding, MessengerBindingCode
logger = logging.getLogger(__name__)
@@ -39,7 +40,11 @@ def notifier_integrations(context):
provider__in=(IntegrationProvider.TELEGRAM, IntegrationProvider.MAX),
config__purpose=NOTIFIER_PURPOSE,
).exclude(secret="")
return qs.filter(organization=context.organization)
# Демо-подключения из демо-набора не опрашиваются: токены ненастоящие.
# Отсутствие ключа в JSON — тоже «не демо», поэтому isnull, а не exclude.
return qs.filter(organization=context.organization).filter(
Q(config__demoSeed__isnull=True) | Q(config__demoSeed=False)
)
def deep_link(integration: Integration, code: str) -> str:
@@ -8,6 +8,8 @@ from django.utils import timezone
from chatballs.ai.knowledge_categories import ensure_uncategorized_category
from chatballs.events.services import DomainEvent, enqueue_event
from chatballs.i18n import t
from chatballs.i18n.audience import customer_language
from chatballs.identity.audit import record_audit_event
from chatballs.identity.invitation_service import issue_invitation
from chatballs.identity.models import (
@@ -173,7 +175,7 @@ def _provision_active_owner(
user=owner_user,
organization=org,
role=EmployeeRole.OWNER,
position_title=_owner_position_title(),
position_title=_owner_position_title(org),
)
record_audit_event(
action="organization.provisioned",
@@ -228,8 +230,10 @@ def _provision_pending_owner(
)
def _owner_position_title() -> str:
return "Владелец"
def _owner_position_title(org: Organization) -> str:
# Должность хранится текстом, поэтому пишется сразу на языке организации,
# а не оператора платформы: переводить её потом будет нечем.
return t("setup.owner_position", language=customer_language(org))
def _safe_message(error: Exception) -> str:
@@ -0,0 +1,100 @@
from __future__ import annotations
from django.db import connection
from django.test import TransactionTestCase, override_settings
from rest_framework.test import APIClient
from chatballs.identity.models import (
EmployeeRole,
HumanUser,
OrganizationInvitation,
OrganizationMembership,
)
from chatballs.platform.models import OrganizationProvisioning, ProvisioningStatus
from chatballs.platform.testing import create_platform_operator
@override_settings(ROOT_URLCONF="chatballs_backend.urls_platform")
class PlatformRoleProvisioningTests(TransactionTestCase):
"""Провижининг под реальной runtime-ролью platform (tenancy/0031).
Остальные тесты ходят в базу владельцем кластера и не заметили бы
отсутствующий GRANT: в деплое backend-platform работает ролью
chatballs_platform, и без прав на платформенные таблицы уже проверка
токена падала с «permission denied». Здесь весь HTTP-запрос выполняется
под этой ролью — от чтения токена до записи приглашения владельца.
"""
def setUp(self) -> None:
self.operator, self.token = create_platform_operator()
def _post(self, token: str, *, slug: str, owner_email: str, key: str):
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Token {token}")
with connection.cursor() as cursor:
cursor.execute("SET ROLE chatballs_runtime_platform")
try:
return client.post(
"/api/v1/organizations",
data={
"name": "Role Co",
"slug": slug,
"owner_email": owner_email,
"timezone": "Europe/Moscow",
"currency": "RUB",
},
format="json",
HTTP_IDEMPOTENCY_KEY=key,
)
finally:
with connection.cursor() as cursor:
cursor.execute("RESET ROLE")
def test_unknown_token_is_rejected_not_crashed(self) -> None:
response = self._post(
"ctp_not_a_real_token", slug="role-co", owner_email="x@example.test", key="k0"
)
self.assertEqual(response.status_code, 401, response.content)
def test_active_owner_is_provisioned_under_platform_role(self) -> None:
HumanUser.objects.create_user(email="role-owner@example.test")
response = self._post(
self.token, slug="role-co", owner_email="role-owner@example.test", key="k1"
)
self.assertEqual(response.status_code, 201, response.content)
payload = response.json()
self.assertEqual(payload["owner"]["state"], "active")
self.assertEqual(payload["provisioning"]["status"], ProvisioningStatus.COMPLETED)
self.assertTrue(
OrganizationMembership.objects.filter(
organization__slug="role-co", role=EmployeeRole.OWNER
).exists()
)
record = OrganizationProvisioning.objects.get(idempotency_key="k1")
self.assertEqual(record.status, ProvisioningStatus.COMPLETED)
def test_pending_owner_gets_invitation_under_platform_role(self) -> None:
response = self._post(
self.token, slug="role-co", owner_email="new-owner@example.test", key="k2"
)
self.assertEqual(response.status_code, 201, response.content)
self.assertEqual(response.json()["owner"]["state"], "pending_invitation")
self.assertTrue(
OrganizationInvitation.objects.filter(
organization__slug="role-co",
email="new-owner@example.test",
role=EmployeeRole.OWNER,
).exists()
)
def test_replay_is_idempotent_under_platform_role(self) -> None:
HumanUser.objects.create_user(email="role-owner@example.test")
first = self._post(
self.token, slug="role-co", owner_email="role-owner@example.test", key="k3"
)
second = self._post(
self.token, slug="role-co", owner_email="role-owner@example.test", key="k3"
)
self.assertEqual(first.status_code, 201, first.content)
self.assertEqual(second.status_code, 200, second.content)
self.assertEqual(OrganizationProvisioning.objects.filter(idempotency_key="k3").count(), 1)
+7 -3
View File
@@ -13,6 +13,7 @@ from chatballs.platform.payloads import owner_state_for, provisioning_result_pay
from chatballs.platform.permissions import HasPlatformCapability
from chatballs.platform.provisioning_service import provision_organization
from chatballs.platform.validation import parse_provisioning_body
from chatballs.tenancy.database import tenant_atomic
_IDEMPOTENCY_HEADER = "Idempotency-Key"
@@ -44,9 +45,12 @@ class OrganizationProvisionView(APIView):
result = provision_organization(command=command, operator=request.user)
except ProvisioningError as error:
return Response({"detail": str(error)}, status=error.status_code)
owner_membership = OrganizationMembership.objects.filter(
organization=result.organization, role=EmployeeRole.OWNER
).first()
# Членство — тенантная строка: под ролью platform без tenant-контекста
# RLS её не покажет, и ответ назвал бы активного владельца ожидающим.
with tenant_atomic(result.organization.pk):
owner_membership = OrganizationMembership.objects.filter(
organization=result.organization, role=EmployeeRole.OWNER
).first()
payload = provisioning_result_payload(
provisioning=result.provisioning,
organization=result.organization,
@@ -3,12 +3,12 @@ from rest_framework.permissions import AllowAny
from rest_framework.request import Request
from rest_framework.views import APIView
from chatballs.identity.models import Organization
from chatballs.support_portals.content_services import INLINE_CONTENT_TYPES
from chatballs.support_portals.models import PortalArticleFile
from chatballs.tenancy.context import TenantContext
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.ingress import portal_article_file_route
from chatballs.tenancy.lookup import load_organization
class PortalArticleFileView(APIView):
@@ -26,10 +26,9 @@ class PortalArticleFileView(APIView):
route = portal_article_file_route(str(public_id))
if route is None:
raise Http404
try:
organization = Organization.objects.get(pk=route.organization_id)
except Organization.DoesNotExist as error:
raise Http404 from error
organization = load_organization(route.organization_id)
if organization is None:
raise Http404
context = TenantContext.for_resource(organization)
with tenant_atomic(context):
article_file = PortalArticleFile.objects.filter(
@@ -41,11 +41,13 @@ class SupportPortalHostBoundaryMiddleware:
return True
# Адреса, которые человек задал сам: тот, на котором прошли мастер, и
# предыдущий — чтобы смена адреса в «Настройках» не выбрасывала того,
# кто её делает, до того как новый домен вообще заработал.
from chatballs.identity.instance_settings import accepted_hosts
# кто её делает, до того как новый домен вообще заработал. Промах
# перечитывает кэш: соседний процесс gunicorn мог ещё не увидеть адрес,
# который мастер записал секунду назад.
from chatballs.identity.instance_settings import host_is_accepted
try:
if host and host in {normalize_domain(item) for item in accepted_hosts()}:
if host_is_accepted(host):
return True
except Exception:
pass
@@ -5,7 +5,6 @@ from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView
from chatballs.i18n import t
from chatballs.identity.models import Organization
from chatballs.support_portals.content_services import record_feedback
from chatballs.support_portals.models import SupportPortal
from chatballs.support_portals.selectors import category_article_counts, public_articles
@@ -17,6 +16,7 @@ from chatballs.support_portals.serializers import (
from chatballs.tenancy.context import TenantContext
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.ingress import support_portal_route
from chatballs.tenancy.lookup import load_organization
class PublicPortalView(APIView):
@@ -28,9 +28,8 @@ class PublicPortalView(APIView):
route = support_portal_route(hostname)
if route is None:
return None
try:
organization = Organization.objects.get(id=route.organization_id)
except Organization.DoesNotExist:
organization = load_organization(route.organization_id)
if organization is None:
return None
context = TenantContext.for_resource(organization)
with tenant_atomic(context):
+55 -3
View File
@@ -3,7 +3,6 @@ from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from django.conf import settings
from django.db import connections
@@ -14,8 +13,9 @@ class IngressRoute:
def _rows(query: str, parameters: list[Any]) -> list[tuple]:
alias = "default" if settings.TESTING else "platform"
with connections[alias].cursor() as cursor:
# Каталоги — security-barrier вьюхи, на них есть SELECT у роли app
# (tenancy/0032): чтение идёт по основному соединению процесса.
with connections["default"].cursor() as cursor:
cursor.execute(query, parameters)
return list(cursor.fetchall())
@@ -64,6 +64,16 @@ def call_invite_route(token_hash: str) -> IngressRoute | None:
return _unique_route("call_invite_directory", token_hash)
def invitation_route(token_hash: str) -> IngressRoute | None:
"""Приглашение в организацию по хэшу токена из письма (/join).
Ссылка открывается без контекста — токен и есть единственный ключ. Каталог
(tenancy/0035) отдаёт организацию, а само приглашение читается уже в ней.
"""
return _unique_route("invitation_directory", token_hash)
def call_session_route(call_session_id: str) -> IngressRoute | None:
return _unique_route("call_session_directory", call_session_id)
@@ -84,3 +94,45 @@ def support_portal_route(hostname: str) -> IngressRoute | None:
return _unique_route("support_portal_directory", hostname.strip().lower().rstrip("."))
# Каталог организаций: id по публичному id или слагу, публичный id по id и
# список всех id. Роль app видит строку организации только в её контексте
# (tenancy/0033), а сюда приходят до того, как контекст открыт.
def organization_route_by_public_id(public_id: str) -> IngressRoute | None:
rows = _rows(
"SELECT organization_id, public_id FROM chatballs.organization_directory "
"WHERE public_id = %s::uuid",
[public_id],
)
if len(rows) != 1:
return None
return IngressRoute(organization_id=int(rows[0][0]), resource_id=str(rows[0][1]))
def organization_route_by_slug(slug: str) -> IngressRoute | None:
rows = _rows(
"SELECT organization_id, slug FROM chatballs.organization_directory WHERE slug = %s",
[slug],
)
if len(rows) != 1:
return None
return IngressRoute(organization_id=int(rows[0][0]), resource_id=str(rows[0][1]))
def organization_public_id_of(organization_id: int) -> str | None:
rows = _rows(
"SELECT public_id FROM chatballs.organization_directory WHERE organization_id = %s",
[int(organization_id)],
)
return str(rows[0][0]) if rows else None
def organization_ids() -> list[int]:
return [
int(row[0])
for row in _rows(
"SELECT organization_id FROM chatballs.organization_directory ORDER BY organization_id",
[],
)
]
+74
View File
@@ -0,0 +1,74 @@
"""Поиск организации там, где tenant-контекста ещё нет.
Роль app видит строку организации только в контексте этой организации
(tenancy/0033). Входы, которые начинаются с внешнего ключа — адрес, публичный
id, слаг, id из outbox-события, — сначала находят id через security-barrier
каталог ``chatballs.organization_directory`` и лишь затем открывают контекст и
читают строку. Так процесс приложения не перечисляет чужие организации.
"""
from __future__ import annotations
from collections.abc import Iterator
from django.db import DEFAULT_DB_ALIAS, connections
from chatballs.identity.models import Organization
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.ingress import (
organization_ids,
organization_route_by_public_id,
organization_route_by_slug,
)
def load_organization(organization_id: int) -> Organization | None:
"""Строка организации по id: читается в её собственном контексте."""
with tenant_atomic(int(organization_id)):
return Organization.objects.filter(pk=organization_id).first()
def organization_by_public_id(public_id: object) -> Organization | None:
route = organization_route_by_public_id(str(public_id))
return load_organization(route.organization_id) if route is not None else None
def organization_by_slug(slug: str) -> Organization | None:
route = organization_route_by_slug(slug)
return load_organization(route.organization_id) if route is not None else None
def iter_organizations() -> Iterator[Organization]:
"""Все организации установки по одной, каждая в своём контексте (воркер)."""
for organization_id in organization_ids():
organization = load_organization(organization_id)
if organization is not None:
yield organization
def reserve_organization_id(*, using: str = DEFAULT_DB_ALIAS) -> int:
"""Выделить id для будущей организации до INSERT.
Роль app видит строку организации только в её контексте, а INSERT с
RETURNING обязан вернуть видимую строку. Поэтому мастер первого запуска
берёт id из последовательности заранее, открывает контекст этого id и уже
в нём вставляет строку.
"""
with connections[using].cursor() as cursor:
cursor.execute("SELECT nextval('identity_organization_id_seq')")
return int(cursor.fetchone()[0])
def instance_has_organizations(*, using: str = DEFAULT_DB_ALIAS) -> bool:
"""Есть ли в установке хоть одна организация — без tenant-контекста.
Проверку делает SECURITY DEFINER-функция (tenancy/0032): та же, что держит
политику «первая организация» мастера первого запуска.
"""
with connections[using].cursor() as cursor:
cursor.execute("SELECT chatballs.instance_has_organizations()")
return bool(cursor.fetchone()[0])
@@ -1,15 +1,16 @@
from __future__ import annotations
import json
import uuid
from datetime import UTC, datetime
from pathlib import Path
from django.core.management.base import BaseCommand, CommandError
from chatballs.ai.models import KnowledgeAttachment
from chatballs.identity.models import Organization
from chatballs.tenancy.context import TenantContext
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.lookup import organization_by_public_id
from chatballs.tenancy.media_migration import (
copy_attachment,
reconcile_attachment_storage_usage,
@@ -34,11 +35,11 @@ class Command(BaseCommand):
def handle(self, *args, **options) -> None:
try:
organization = Organization.objects.get(
public_id=options["organization_public_id"]
)
except (Organization.DoesNotExist, ValueError) as error:
organization = organization_by_public_id(uuid.UUID(str(options["organization_public_id"])))
except ValueError as error:
raise CommandError("Organization not found") from error
if organization is None:
raise CommandError("Organization not found")
source_root = Path(options["source_root"])
manifest_path = Path(options["manifest"])
+8 -3
View File
@@ -8,9 +8,10 @@ from django.http import Http404, HttpRequest, HttpResponse
from django.urls import Resolver404, resolve
from chatballs.events.context import get_correlation_id
from chatballs.identity.models import Organization, OrganizationMembership
from chatballs.identity.models import OrganizationMembership
from chatballs.tenancy.context import TenantContext
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.lookup import organization_by_public_id
class TenantContextMiddleware:
@@ -47,9 +48,13 @@ class TenantContextMiddleware:
raise Http404
try:
public_id = uuid.UUID(match.group("public_id"))
organization = Organization.objects.get(public_id=public_id)
except (ValueError, Organization.DoesNotExist) as error:
except ValueError as error:
raise Http404 from error
# Строка организации видна роли app только в её контексте: id берётся
# из каталога, а сама строка читается уже внутри tenant_atomic.
organization = organization_by_public_id(public_id)
if organization is None:
raise Http404
with tenant_atomic(organization.pk):
try:
@@ -1,6 +1,9 @@
from django.db import migrations
# Исторический список на момент миграции: identity_department и три таблицы
# access-профилей позже удалены (identity/0020), ai_knowledgedepartment — в
# ai/0014. Актуальное покрытие RLS — сумма всех миграций tenancy, не эта константа.
TENANT_TABLES = (
"identity_department",
"identity_employeeprofile",
@@ -0,0 +1,53 @@
# Таблицы платформенного приложения (operator, token, provisioning) появились
# после tenancy/0003, и ни одна миграция не выдала на них прав runtime-роли
# platform. В деплое backend-platform работает ролью chatballs_platform, поэтому
# уже проверка токена падала «permission denied for table platform_platformtoken»,
# а вместе с ней — весь POST /api/v1/organizations (SPEC-HUB-0021 §12). Тесты
# этого не видели: они ходят в базу владельцем кластера.
#
# Здесь роль platform получает DML на платформенные таблицы (без DELETE: токены
# отзываются, записи провижининга хранятся) и на приглашения — их выписывает тот
# же провижининг для будущего владельца внутри set_local_tenant(new_org_id), под
# той же tenant-политикой, что в 0005 для членств.
from django.db import migrations
PLATFORM_POLICY = "chatballs_platform_tenant_provisioning"
GRANTS = f"""
GRANT SELECT, INSERT, UPDATE ON platform_platformoperator, platform_platformtoken,
platform_organizationprovisioning TO chatballs_runtime_platform;
GRANT USAGE, SELECT ON SEQUENCE platform_platformoperator_id_seq,
platform_platformtoken_id_seq, platform_organizationprovisioning_id_seq
TO chatballs_runtime_platform;
GRANT SELECT, INSERT, UPDATE ON identity_organizationinvitation
TO chatballs_runtime_platform;
GRANT USAGE, SELECT ON SEQUENCE identity_organizationinvitation_id_seq
TO chatballs_runtime_platform;
DROP POLICY IF EXISTS {PLATFORM_POLICY} ON identity_organizationinvitation;
CREATE POLICY {PLATFORM_POLICY} ON identity_organizationinvitation
FOR ALL TO chatballs_runtime_platform
USING (organization_id = chatballs.current_organization_id())
WITH CHECK (organization_id = chatballs.current_organization_id());
"""
REVOKE = f"""
DROP POLICY IF EXISTS {PLATFORM_POLICY} ON identity_organizationinvitation;
REVOKE ALL ON identity_organizationinvitation FROM chatballs_runtime_platform;
REVOKE ALL ON SEQUENCE identity_organizationinvitation_id_seq
FROM chatballs_runtime_platform;
REVOKE ALL ON platform_platformoperator, platform_platformtoken,
platform_organizationprovisioning FROM chatballs_runtime_platform;
REVOKE ALL ON SEQUENCE platform_platformoperator_id_seq,
platform_platformtoken_id_seq, platform_organizationprovisioning_id_seq
FROM chatballs_runtime_platform;
"""
class Migration(migrations.Migration):
dependencies = [
("tenancy", "0030_encrypted_column_widths"),
("platform", "0001_initial"),
]
operations = [migrations.RunSQL(GRANTS, REVOKE)]
@@ -0,0 +1,72 @@
# Процесс backend-app держал platform-соединение ради двух вещей: чтения
# каталогов входа (виджет, портал, приглашение в звонок) и мастера первого
# запуска. Из-за этого пароль роли platform лежал в каждом процессе стека.
#
# Каталоги — security-barrier вьюхи над тенантными таблицами: они и задуманы как
# безопасный вход без tenant-контекста, роль app получает на них SELECT.
#
# Мастер первого запуска создаёт первую организацию. Право INSERT на
# identity_organization у роли app появляется под политикой «пока организаций
# нет»: проверку делает SECURITY DEFINER-функция от chatballs_schema (иначе
# политика ссылалась бы на свою же таблицу и Postgres отказал бы за рекурсию).
# После первой организации INSERT для app закрыт навсегда — создавать следующие
# по-прежнему может только роль platform (SPEC-HUB-0021 §10).
from django.db import migrations
INGRESS_VIEWS = (
"attachment_directory",
"call_invite_directory",
"call_session_directory",
"membership_directory",
"organization_directory",
"portal_article_file_directory",
"support_portal_directory",
"web_channel_directory",
"web_session_directory",
"web_widget_directory",
)
BOOTSTRAP_POLICY = "chatballs_organization_app_bootstrap"
BOOTSTRAP_SQL = f"""
CREATE OR REPLACE FUNCTION chatballs.instance_has_organizations() RETURNS boolean
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public, pg_temp AS $$
SELECT EXISTS (SELECT 1 FROM identity_organization)
$$;
ALTER FUNCTION chatballs.instance_has_organizations() OWNER TO chatballs_schema;
REVOKE ALL ON FUNCTION chatballs.instance_has_organizations() FROM PUBLIC;
GRANT EXECUTE ON FUNCTION chatballs.instance_has_organizations()
TO chatballs_runtime_app, chatballs_schema;
GRANT INSERT ON identity_organization TO chatballs_runtime_app;
DROP POLICY IF EXISTS {BOOTSTRAP_POLICY} ON identity_organization;
CREATE POLICY {BOOTSTRAP_POLICY} ON identity_organization
FOR INSERT TO chatballs_runtime_app
WITH CHECK (NOT chatballs.instance_has_organizations());
"""
BOOTSTRAP_REVERSE_SQL = f"""
DROP POLICY IF EXISTS {BOOTSTRAP_POLICY} ON identity_organization;
REVOKE INSERT ON identity_organization FROM chatballs_runtime_app;
DROP FUNCTION IF EXISTS chatballs.instance_has_organizations();
"""
def grant_ingress(apps, schema_editor):
for view in INGRESS_VIEWS:
schema_editor.execute(f"GRANT SELECT ON chatballs.{view} TO chatballs_runtime_app")
schema_editor.execute(BOOTSTRAP_SQL)
def revoke_ingress(apps, schema_editor):
schema_editor.execute(BOOTSTRAP_REVERSE_SQL)
for view in INGRESS_VIEWS:
schema_editor.execute(f"REVOKE SELECT ON chatballs.{view} FROM chatballs_runtime_app")
class Migration(migrations.Migration):
dependencies = [
("tenancy", "0031_platform_provisioning_table_grants"),
]
operations = [migrations.RunPython(grant_ingress, revoke_ingress)]
@@ -0,0 +1,30 @@
# Роль app читала таблицу организаций целиком (0012: SELECT USING (true)) —
# любой процесс приложения без tenant-контекста мог перечислить чужие
# организации с именами, слагами и часовыми поясами. Теперь строка видна
# только в контексте своей организации. Входы без контекста (адрес, публичный
# id, слаг, id из outbox) идут через security-barrier каталог
# chatballs.organization_directory (tenancy/lookup), а мастер первого запуска
# спрашивает о наличии организаций SECURITY DEFINER-функцию (0032).
from django.db import migrations
SCOPED = """
DROP POLICY IF EXISTS chatballs_organization_app_select ON identity_organization;
CREATE POLICY chatballs_organization_app_select ON identity_organization
FOR SELECT TO chatballs_runtime_app
USING (id = chatballs.current_organization_id());
"""
UNSCOPED = """
DROP POLICY IF EXISTS chatballs_organization_app_select ON identity_organization;
CREATE POLICY chatballs_organization_app_select ON identity_organization
FOR SELECT TO chatballs_runtime_app
USING (true);
"""
class Migration(migrations.Migration):
dependencies = [
("tenancy", "0032_app_role_ingress_and_bootstrap"),
]
operations = [migrations.RunSQL(SCOPED, UNSCOPED)]
@@ -0,0 +1,26 @@
# Состояние обновлений установки (updates_updatestate): одна строка на
# инсталляцию, не тенантная таблица, без RLS — как настройки установки (0028).
# Пишут её приложение (запрос установки, синхронизация статуса) и воркер
# (периодическая проверка канала релизов) — оба ролью app.
from django.db import migrations
GRANTS = """
GRANT SELECT, INSERT, UPDATE ON updates_updatestate
TO chatballs_runtime_app, chatballs_runtime_platform;
GRANT USAGE, SELECT ON SEQUENCE updates_updatestate_id_seq
TO chatballs_runtime_app, chatballs_runtime_platform;
"""
REVOKE = """
REVOKE ALL ON updates_updatestate
FROM chatballs_runtime_app, chatballs_runtime_platform;
"""
class Migration(migrations.Migration):
dependencies = [
("tenancy", "0033_organization_select_scope"),
("updates", "0001_initial"),
]
operations = [migrations.RunSQL(GRANTS, REVOKE)]
@@ -0,0 +1,55 @@
# Две дыры мультиорганизационности, обе на роли app.
#
# 1. Организации создаёт человек из интерфейса (кнопка «Добавить организацию»
# в переключателе, дизайн-базлайн v2, A1), а не только оператор платформы.
# Политика bootstrap из 0032 пускала INSERT роли app лишь до первой
# организации. Теперь строка вставляется в контексте своего же id: сервис
# заранее берёт id из последовательности (tenancy.lookup.reserve_organization_id),
# открывает tenant_atomic(id) и уже в нём пишет строку — ровно так, как
# это делал мастер первого запуска. Без контекста INSERT по-прежнему закрыт.
#
# 2. Ссылка-приглашение /join открывается без tenant-контекста: токен из
# письма — единственное, что есть. Таблица приглашений под RLS, и роль app
# без контекста не находила приглашение вовсе. Security-barrier каталог
# invitation_directory отдаёт по хэшу токена id организации и приглашения —
# по образцу call_invite_directory (0004).
from django.db import migrations
BOOTSTRAP_POLICY = "chatballs_organization_app_bootstrap"
CREATE_POLICY = "chatballs_organization_app_create"
FORWARD_SQL = f"""
DROP POLICY IF EXISTS {BOOTSTRAP_POLICY} ON identity_organization;
DROP POLICY IF EXISTS {CREATE_POLICY} ON identity_organization;
CREATE POLICY {CREATE_POLICY} ON identity_organization
FOR INSERT TO chatballs_runtime_app
WITH CHECK (id = chatballs.current_organization_id());
CREATE OR REPLACE VIEW chatballs.invitation_directory
WITH (security_barrier = true) AS
SELECT invitation.id AS resource_id,
invitation.organization_id,
invitation.token_hash AS lookup_key
FROM identity_organizationinvitation invitation;
ALTER VIEW chatballs.invitation_directory OWNER TO chatballs_schema;
REVOKE ALL ON chatballs.invitation_directory FROM PUBLIC;
GRANT SELECT ON chatballs.invitation_directory
TO chatballs_runtime_app, chatballs_runtime_platform;
"""
REVERSE_SQL = f"""
DROP VIEW IF EXISTS chatballs.invitation_directory;
DROP POLICY IF EXISTS {CREATE_POLICY} ON identity_organization;
DROP POLICY IF EXISTS {BOOTSTRAP_POLICY} ON identity_organization;
CREATE POLICY {BOOTSTRAP_POLICY} ON identity_organization
FOR INSERT TO chatballs_runtime_app
WITH CHECK (NOT chatballs.instance_has_organizations());
"""
class Migration(migrations.Migration):
dependencies = [
("tenancy", "0034_update_state_grants"),
]
operations = [migrations.RunSQL(FORWARD_SQL, REVERSE_SQL)]
+7 -5
View File
@@ -1,10 +1,12 @@
"""Принудительный выбор соединения БД для блока кода.
Runtime backend-app держит два соединения: ``default`` (роль app) и
``platform`` (роль platform, права на создание организаций — SPEC-HUB-0021).
Обычные запросы идут в ``default``; операции уровня инстанса (мастер первого
запуска) выполняются целиком на ``platform`` через ``use_database("platform")``,
чтобы транзакция и RLS-контекст жили на одном соединении.
Алиас ``platform`` (роль platform, права на создание организаций —
SPEC-HUB-0021) есть только у процессов, которым он нужен: у платформенной
поверхности и у воркера, захватывающего outbox всех организаций
(см. ``settings_database.build_databases``). У backend-app его нет: каталоги
входа и мастер первого запуска работают ролью app (tenancy/0032).
``use_database`` переводит блок кода целиком на выбранный алиас, чтобы
транзакция и RLS-контекст жили на одном соединении.
"""
from __future__ import annotations
+14 -15
View File
@@ -1,12 +1,14 @@
"""Настройки хранилища файлов в «Настройках» администратора.
GET company/administration/storage/ — текущее состояние (секреты замаскированы)
PATCH company/administration/storage/ — сохранить; переключение на S3 проверяет доступ
POST company/administration/storage/check/ — проверить реквизиты (без сохранения)
POST company/administration/storage/migrate/ — перенести локальные файлы в S3 (worker)
GET instance/storage/ — текущее состояние (секреты замаскированы)
PATCH instance/storage/ — сохранить; переключение на S3 проверяет доступ
POST instance/storage/check/ — проверить реквизиты (без сохранения)
POST instance/storage/migrate/ — перенести локальные файлы в S3 (worker)
Настройка общая для инстанса: файлы всех организаций лежат в одном месте под
своими префиксами organizations/<public_id>/.
своими префиксами organizations/<public_id>/. Поэтому путь без организации,
а менять её вправе только администратор установки (identity.instance_access);
записи аудита и событие переноса — уровня установки, без организации.
"""
from __future__ import annotations
@@ -16,10 +18,10 @@ from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from chatballs.api.permissions import HasCapability
from chatballs.events.services import DomainEvent, enqueue_event
from chatballs.i18n import t
from chatballs.identity.audit import record_audit_event
from chatballs.identity.instance_access import InstanceSettingsPermission, IsInstanceAdmin
from chatballs.tenancy import storage_settings as ss
STORAGE_MIGRATION_REQUESTED = "storage.migration_requested"
@@ -81,8 +83,7 @@ def _apply_fields(row: ss.StorageSettings, body: dict) -> ss.StorageConfig:
class StorageSettingsView(APIView):
permission_classes = [HasCapability]
required_capabilities = {"GET": "settings.view", "PATCH": "company.manage"}
permission_classes = [InstanceSettingsPermission]
def get(self, request: Request) -> Response:
return Response({"storage": storage_payload(ss.StorageSettings.load())})
@@ -113,7 +114,7 @@ class StorageSettingsView(APIView):
record_audit_event(
action="administration.storage_updated",
actor=request.user,
organization=request.tenant_context.organization,
organization=None,
object_type="StorageSettings",
object_id=str(row.pk),
payload={"backend": backend, "bucket": row.s3_bucket if backend == ss.StorageBackend.S3 else ""},
@@ -125,8 +126,7 @@ class StorageSettingsView(APIView):
class StorageCheckView(APIView):
"""Проверка реквизитов S3 без сохранения: пробная запись и удаление объекта."""
permission_classes = [HasCapability]
required_capability = "company.manage"
permission_classes = [IsInstanceAdmin]
def post(self, request: Request) -> Response:
row = ss.StorageSettings.load()
@@ -145,8 +145,7 @@ class StorageCheckView(APIView):
class StorageMigrateView(APIView):
"""Перенос локальных файлов в S3 — фоновой задачей worker'а."""
permission_classes = [HasCapability]
required_capability = "company.manage"
permission_classes = [IsInstanceAdmin]
def post(self, request: Request) -> Response:
row = ss.StorageSettings.load()
@@ -169,13 +168,13 @@ class StorageMigrateView(APIView):
aggregate_id=str(row.pk),
event_type=STORAGE_MIGRATION_REQUESTED,
payload={"requestedBy": request.user.id},
tenant_context=request.tenant_context,
tenant_context=None,
)
)
record_audit_event(
action="administration.storage_migration_requested",
actor=request.user,
organization=request.tenant_context.organization,
organization=None,
object_type="StorageSettings",
object_id=str(row.pk),
request=request,
@@ -0,0 +1,53 @@
from __future__ import annotations
from django.db import DatabaseError, connection, transaction
from django.test import TransactionTestCase
from chatballs.identity.models import EmployeeRole, Organization, OrganizationMembership
from chatballs.identity.setup import SetupInput, complete_setup
from chatballs.tenancy.ingress import organization_ids
from chatballs.tenancy.lookup import instance_has_organizations
class AppRoleBootstrapPolicyTests(TransactionTestCase):
"""Мастер первого запуска работает ролью app (tenancy/0032).
Пока организаций нет, роль app вправе создать первую; сразу после этого
INSERT для неё закрыт, и следующие организации создаёт только роль
platform. Проверяется под реальной runtime-ролью, а не владельцем кластера.
"""
def _set_role(self, role: str) -> None:
with connection.cursor() as cursor:
cursor.execute(f"SET ROLE {role}")
def _reset_role(self) -> None:
with connection.cursor() as cursor:
cursor.execute("RESET ROLE")
def test_setup_creates_first_organization_under_app_role_only_once(self) -> None:
self._set_role("chatballs_runtime_app")
try:
result = complete_setup(
SetupInput(
organization_name="Bootstrap Co",
full_name="First Owner",
email="first-owner@example.test",
password="Long-and-strong-passphrase-42",
)
)
# Без контекста строки организаций роли app не видны (tenancy/0033):
# факт создания проверяется через каталог.
self.assertTrue(instance_has_organizations())
self.assertEqual(len(organization_ids()), 1)
with transaction.atomic(), self.assertRaises(DatabaseError):
Organization.objects.create(name="Second", slug="second-co")
finally:
self._reset_role()
self.assertTrue(
OrganizationMembership.objects.filter(
organization=result.organization,
user=result.owner,
role=EmployeeRole.OWNER,
).exists()
)
+108 -2
View File
@@ -1,10 +1,20 @@
from datetime import timedelta
from django.core.exceptions import ValidationError
from django.db import DatabaseError, connection, transaction
from django.test import TransactionTestCase
from django.utils import timezone
from chatballs.ai.knowledge_categories import ensure_uncategorized_category
from chatballs.ai.models import AIAgent, Knowledge
from chatballs.channels.models import Channel
from chatballs.identity.group_models import EmployeeGroup
from chatballs.identity.invitation_models import OrganizationInvitation
from chatballs.identity.invitation_service import (
invitation_preview,
issue_invitation,
pending_invitation_for_token,
)
from chatballs.identity.models import (
AuditEvent,
AuditResult,
@@ -13,7 +23,8 @@ from chatballs.identity.models import (
Organization,
OrganizationMembership,
)
from chatballs.tenancy.database import current_tenant_id, set_local_tenant
from chatballs.tenancy.database import current_tenant_id, set_local_tenant, tenant_atomic
from chatballs.tenancy.lookup import reserve_organization_id
from chatballs.tenancy.models import StorageReservation
from chatballs.testing import TenantAPIClient
@@ -162,7 +173,9 @@ class RowLevelSecurityTests(TransactionTestCase):
with self.assertRaises(DatabaseError), transaction.atomic():
# Чужая организация в контексте первой не видна вовсе (tenancy/0033):
# проверка внешнего ключа в full_clean отказывает ещё до INSERT.
with self.assertRaises((DatabaseError, ValidationError)), transaction.atomic():
self._set_role("chatballs_runtime_app")
@@ -426,6 +439,58 @@ class RowLevelSecurityTests(TransactionTestCase):
def test_app_role_reads_ingress_directory_and_adds_organizations_only_in_context(self) -> None:
# Каталоги входа доступны роли app (tenancy/0032): backend-app
# обходится без platform-соединения.
with transaction.atomic():
self._set_role("chatballs_runtime_app")
with connection.cursor() as cursor:
cursor.execute(
"SELECT organization_id FROM chatballs.membership_directory "
"WHERE user_id = %s",
[self.user.id],
)
self.assertEqual(cursor.fetchone()[0], self.first.id)
# Без контекста INSERT организации для app закрыт (tenancy/0035).
with self.assertRaises(DatabaseError), transaction.atomic():
self._set_role("chatballs_runtime_app")
Organization.objects.create(name="Third", slug="rls-third")
# В контексте заранее выделенного id — открыт: так работает кнопка
# «Добавить организацию» (identity.organization_creation).
with transaction.atomic():
self._set_role("chatballs_runtime_app")
organization_id = reserve_organization_id()
with tenant_atomic(organization_id):
Organization(id=organization_id, name="Third", slug="rls-third").save(force_insert=True)
self.assertEqual(Organization.objects.get(pk=organization_id).slug, "rls-third")
self.assertTrue(Organization.objects.filter(slug="rls-third").exists())
# Чужой контекст не подходит: id строки обязан совпасть с контекстом.
with self.assertRaises(DatabaseError), transaction.atomic():
self._set_role("chatballs_runtime_app")
with tenant_atomic(self.first.id):
Organization(id=reserve_organization_id(), name="Fourth", slug="rls-fourth").save(force_insert=True)
def test_app_role_finds_invitation_by_token_without_context(self) -> None:
# Ссылка /join открывается без контекста: приглашение находит каталог
# invitation_directory (tenancy/0035), иначе роль app видела бы пустоту.
issued = issue_invitation(
organization=self.second,
email="invited@example.test",
role=EmployeeRole.EMPLOYEE,
expires_at=timezone.now() + timedelta(days=1),
created_by=None,
)
with transaction.atomic():
self._set_role("chatballs_runtime_app")
self.assertEqual(OrganizationInvitation.objects.count(), 0)
invitation = pending_invitation_for_token(issued.token)
self.assertIsNotNone(invitation)
self.assertEqual(invitation.id, issued.invitation.id)
self.assertEqual(invitation.organization.slug, "rls-second")
self.assertIsNone(pending_invitation_for_token("wrong-token"))
preview = invitation_preview(issued.token)
self.assertEqual(preview["organizationName"], "Second")
def test_platform_role_can_only_use_ingress_directory(self) -> None:
with self.assertRaises(DatabaseError), transaction.atomic():
@@ -464,6 +529,47 @@ class RowLevelSecurityTests(TransactionTestCase):
def test_app_role_sees_organizations_only_in_their_context(self) -> None:
# tenancy/0033: без контекста строк организаций нет, в контексте — своя.
with transaction.atomic():
self._set_role("chatballs_runtime_app")
self.assertEqual(Organization.objects.count(), 0)
set_local_tenant(self.first.id)
self.assertEqual(
list(Organization.objects.values_list("id", flat=True)), [self.first.id]
)
def test_app_role_finds_organizations_through_the_directory(self) -> None:
from chatballs.tenancy.lookup import (
instance_has_organizations,
iter_organizations,
organization_by_public_id,
)
with connection.cursor() as cursor:
cursor.execute("SET ROLE chatballs_runtime_app")
try:
self.assertTrue(instance_has_organizations())
self.assertEqual(
[organization.id for organization in iter_organizations()],
[self.first.id, self.second.id],
)
found = organization_by_public_id(self.second.public_id)
self.assertIsNotNone(found)
self.assertEqual(found.id, self.second.id)
# Сессия собирает членства по каталогу и читает каждую организацию
# в её контексте.
client = TenantAPIClient()
client.force_authenticate(self.user)
session = client.get("/api/v1/auth/session/").json()
self.assertEqual(
[item["organizationPublicId"] for item in session["user"]["memberships"]],
[str(self.first.public_id)],
)
finally:
with connection.cursor() as cursor:
cursor.execute("RESET ROLE")
def test_transaction_local_context_clears_after_commit_and_rollback(self) -> None:
with transaction.atomic():
@@ -43,7 +43,9 @@ class StorageSettingsTests(TestCase):
DynamicTenantStorage._s3_cache = None
self.organization = Organization.objects.create(name="Acme", slug="acme")
self.owner = HumanUser.objects.create_user(email="owner@example.com", password="Password-123", full_name="Owner")
self.owner = HumanUser.objects.create_user(
email="owner@example.com", password="Password-123", full_name="Owner", is_instance_admin=True
)
OrganizationMembership.objects.create(organization=self.organization, user=self.owner, role=EmployeeRole.OWNER, position_title="Owner")
self.client = TenantAPIClient()
self.client.force_authenticate(self.owner)
@@ -133,7 +135,7 @@ class StorageSettingsTests(TestCase):
stored = cursor.fetchone()[0]
self.assertNotEqual(stored, "very-secret")
self.assertEqual(ss.StorageSettings.load().s3_secret_key, "very-secret")
payload = self.client.get("/api/v1/company/administration/storage/").json()["storage"]
payload = self.client.get("/api/v1/instance/storage/").json()["storage"]
self.assertEqual(payload["backend"], "S3")
self.assertNotIn("s3SecretKey", payload)
self.assertTrue(payload["s3HasSecretKey"])
@@ -142,7 +144,7 @@ class StorageSettingsTests(TestCase):
def test_patch_to_s3_probes_before_switching(self) -> None:
with mock.patch.object(ss, "build_s3_storage", side_effect=RuntimeError("connection refused")):
response = self.client.patch(
"/api/v1/company/administration/storage/",
"/api/v1/instance/storage/",
{"backend": "S3", "s3Bucket": "demo", "s3AccessKey": "a", "s3SecretKey": "b"},
format="json",
)
@@ -153,7 +155,7 @@ class StorageSettingsTests(TestCase):
factory = _FakeS3Factory(self.bucket.name)
with mock.patch.object(ss, "build_s3_storage", factory):
response = self.client.patch(
"/api/v1/company/administration/storage/",
"/api/v1/instance/storage/",
{"backend": "S3", "s3Bucket": "demo", "s3EndpointUrl": "https://s3.example.com/", "s3AccessKey": "a", "s3SecretKey": "b"},
format="json",
)
@@ -164,11 +166,11 @@ class StorageSettingsTests(TestCase):
self.assertIsNotNone(row.s3_verified_at)
# Пустые секреты в повторном PATCH оставляют прежние.
with mock.patch.object(ss, "build_s3_storage", factory):
self.client.patch("/api/v1/company/administration/storage/", {"backend": "S3", "s3AccessKey": "", "s3SecretKey": ""}, format="json")
self.client.patch("/api/v1/instance/storage/", {"backend": "S3", "s3AccessKey": "", "s3SecretKey": ""}, format="json")
self.assertEqual(ss.StorageSettings.load().s3_secret_key, "b")
def test_check_endpoint_reports_errors_without_saving(self) -> None:
response = self.client.post("/api/v1/company/administration/storage/check/", {"s3Bucket": ""}, format="json")
response = self.client.post("/api/v1/instance/storage/check/", {"s3Bucket": ""}, format="json")
self.assertEqual(response.status_code, 400)
self.assertIn("s3Bucket", response.json()["errors"])
self.assertEqual(ss.StorageSettings.load().s3_bucket, "")
@@ -178,18 +180,18 @@ class StorageSettingsTests(TestCase):
OrganizationMembership.objects.create(organization=self.organization, user=employee, role=EmployeeRole.EMPLOYEE, position_title="Op")
client = TenantAPIClient()
client.force_authenticate(employee)
self.assertEqual(client.get("/api/v1/company/administration/storage/").status_code, 403)
self.assertEqual(client.patch("/api/v1/company/administration/storage/", {"backend": "LOCAL"}, format="json").status_code, 403)
self.assertEqual(client.get("/api/v1/instance/storage/").status_code, 403)
self.assertEqual(client.patch("/api/v1/instance/storage/", {"backend": "LOCAL"}, format="json").status_code, 403)
def test_migration_copies_local_files_to_s3(self) -> None:
with tenant_atomic(self.organization.id):
storages["default"].save(self.key, ContentFile(b"legacy"))
self._enable_s3()
response = self.client.post("/api/v1/company/administration/storage/migrate/")
response = self.client.post("/api/v1/instance/storage/migrate/")
self.assertEqual(response.status_code, 202, response.content)
row = ss.StorageSettings.load()
self.assertEqual(row.migration_status, ss.StorageMigrationStatus.RUNNING)
copied = migrate_local_to_s3(row)
self.assertEqual(copied, 1)
self.assertTrue((__import__("pathlib").Path(self.bucket.name) / self.key).exists())
self.assertEqual(self.client.post("/api/v1/company/administration/storage/migrate/").status_code, 409)
self.assertEqual(self.client.post("/api/v1/instance/storage/migrate/").status_code, 409)
Whitespace-only changes.
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class UpdatesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "chatballs.updates"
verbose_name = "Обновления установки"
+106
View File
@@ -0,0 +1,106 @@
"""Канал релизов: страница релизов GitHub.
Установка спрашивает у GitHub последний релиз репозитория продукта и берёт
из него версию, заметки и ссылку на compose.yaml. Никаких токенов: релизы
публичные, лимита анонимных запросов хватает при проверке раз в несколько
часов.
"""
from __future__ import annotations
import json
import re
import urllib.error
import urllib.request
from dataclasses import dataclass
from datetime import datetime
from django.conf import settings
VERSION_RE = re.compile(r"^\d+\.\d+\.\d+(?:[-.][0-9A-Za-z.]+)?$")
_TIMEOUT_SECONDS = 15
@dataclass(frozen=True, slots=True)
class Release:
version: str
name: str
notes: str
published_at: datetime | None
compose_url: str
page_url: str
class ReleaseChannelError(Exception):
"""Канал релизов недоступен или ответил не тем, что ожидалось."""
def parse_version(tag: str) -> str | None:
version = tag.strip()
if version.startswith(("v", "V")):
version = version[1:]
return version if VERSION_RE.match(version) else None
def version_key(version: str) -> tuple:
"""Ключ сравнения: числовая тройка, затем метка (без метки — новее)."""
core, _, label = version.partition("-")
core = core.split(".")[:3]
numbers = tuple(int(part) for part in core if part.isdigit())
return (numbers, label == "", label)
def is_newer(candidate: str, current: str) -> bool:
if not VERSION_RE.match(candidate) or not VERSION_RE.match(current or ""):
# Dev-сборка без версии: обновлять нечего и не с чем сравнивать.
return False
return version_key(candidate) > version_key(current)
def expected_compose_url(version: str) -> str:
return f"https://github.com/{settings.CHATBALLS_UPDATE_REPO}/releases/download/v{version}/compose.yaml"
def fetch_latest_release() -> Release:
url = f"https://api.github.com/repos/{settings.CHATBALLS_UPDATE_REPO}/releases/latest"
request = urllib.request.Request(
url,
headers={
"Accept": "application/vnd.github+json",
"User-Agent": f"chatballs/{settings.CHATBALLS_VERSION}",
},
)
try:
with urllib.request.urlopen(request, timeout=_TIMEOUT_SECONDS) as response:
payload = json.loads(response.read().decode("utf-8"))
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as error:
raise ReleaseChannelError(str(error)) from error
version = parse_version(str(payload.get("tag_name", "")))
if version is None:
raise ReleaseChannelError(f"unexpected tag {payload.get('tag_name')!r}")
compose_url = next(
(
str(asset.get("browser_download_url", ""))
for asset in payload.get("assets", [])
if asset.get("name") == "compose.yaml"
),
"",
)
if compose_url != expected_compose_url(version):
raise ReleaseChannelError("release has no compose.yaml at the expected address")
published = None
raw_published = str(payload.get("published_at", "")).strip()
if raw_published:
try:
published = datetime.fromisoformat(raw_published.replace("Z", "+00:00"))
except ValueError:
published = None
return Release(
version=version,
name=str(payload.get("name") or f"v{version}"),
notes=str(payload.get("body") or ""),
published_at=published,
compose_url=compose_url,
page_url=str(payload.get("html_url") or ""),
)
@@ -0,0 +1,57 @@
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="UpdateState",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("latest_version", models.CharField(blank=True, default="", max_length=32)),
("latest_name", models.CharField(blank=True, default="", max_length=128)),
("latest_notes", models.TextField(blank=True, default="")),
("latest_published_at", models.DateTimeField(blank=True, null=True)),
("latest_compose_url", models.URLField(blank=True, default="", max_length=512)),
("latest_page_url", models.URLField(blank=True, default="", max_length=512)),
("checked_at", models.DateTimeField(blank=True, null=True)),
("check_error", models.CharField(blank=True, default="", max_length=500)),
("install_version", models.CharField(blank=True, default="", max_length=32)),
(
"install_status",
models.CharField(
choices=[
("IDLE", "Не запускалась"),
("REQUESTED", "Запрошена"),
("RUNNING", "Идёт"),
("DONE", "Завершена"),
("FAILED", "Ошибка"),
],
default="IDLE",
max_length=12,
),
),
("install_message", models.CharField(blank=True, default="", max_length=1000)),
("install_requested_at", models.DateTimeField(blank=True, null=True)),
("install_updated_at", models.DateTimeField(blank=True, null=True)),
(
"install_requested_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to=settings.AUTH_USER_MODEL,
),
),
],
options={"verbose_name": "Обновления установки"},
),
]
Whitespace-only changes.
+60
View File
@@ -0,0 +1,60 @@
"""Состояние обновлений установки: одна строка на инсталляцию.
Не тенантная таблица: версия у установки одна на всех. Проверка релизов
живёт в воркере, установку запрашивает администратор установки, а выполняет
сервис updater вне приложения (ADR-CHATBALLS-0049).
"""
from __future__ import annotations
from django.db import models
class InstallStatus(models.TextChoices):
IDLE = "IDLE", "Не запускалась"
REQUESTED = "REQUESTED", "Запрошена"
RUNNING = "RUNNING", "Идёт"
DONE = "DONE", "Завершена"
FAILED = "FAILED", "Ошибка"
class UpdateState(models.Model):
SINGLETON_PK = 1
# Что известно о последнем релизе на канале.
latest_version = models.CharField(max_length=32, blank=True, default="")
latest_name = models.CharField(max_length=128, blank=True, default="")
latest_notes = models.TextField(blank=True, default="")
latest_published_at = models.DateTimeField(null=True, blank=True)
latest_compose_url = models.URLField(max_length=512, blank=True, default="")
latest_page_url = models.URLField(max_length=512, blank=True, default="")
checked_at = models.DateTimeField(null=True, blank=True)
check_error = models.CharField(max_length=500, blank=True, default="")
# Последняя запрошенная установка.
install_version = models.CharField(max_length=32, blank=True, default="")
install_status = models.CharField(
max_length=12, choices=InstallStatus.choices, default=InstallStatus.IDLE
)
install_message = models.CharField(max_length=1000, blank=True, default="")
install_requested_at = models.DateTimeField(null=True, blank=True)
install_updated_at = models.DateTimeField(null=True, blank=True)
# Кто нажал кнопку — для аудита и подписи в интерфейсе.
install_requested_by = models.ForeignKey(
"identity.HumanUser", on_delete=models.SET_NULL, null=True, blank=True, related_name="+"
)
class Meta:
verbose_name = "Обновления установки"
def __str__(self) -> str:
return f"updates:{self.latest_version or 'нет данных'}"
def save(self, *args, **kwargs):
self.pk = self.SINGLETON_PK
super().save(*args, **kwargs)
@classmethod
def load(cls) -> UpdateState:
obj, _ = cls.objects.get_or_create(pk=cls.SINGLETON_PK)
return obj
+179
View File
@@ -0,0 +1,179 @@
"""Проверка релизов и запрос установки обновления.
Приложение не перезапускает само себя: у публичного процесса нет и не должно
быть доступа к Docker. Оно лишь кладёт запрос в общий том
``CHATBALLS_UPDATES_DIR``, а сервис updater (deploy/updater) забирает его,
проверяет и выполняет ``docker compose pull && up`` уже снаружи. Состояние
установки updater пишет в тот же том файлом ``status``; приложение
переносит его в UpdateState при каждом чтении.
"""
from __future__ import annotations
import json
import logging
from datetime import timedelta
from pathlib import Path
from django.conf import settings
from django.utils import timezone
from chatballs.updates.github import (
ReleaseChannelError,
fetch_latest_release,
is_newer,
)
from chatballs.updates.models import InstallStatus, UpdateState
logger = logging.getLogger(__name__)
REQUEST_FILE = "request.json"
STATUS_FILE = "status.json"
HEARTBEAT_FILE = "heartbeat"
# Пока updater не отметился дольше этого, считаем, что установки из интерфейса нет.
HEARTBEAT_STALE_SECONDS = 90
_STATUS_MAP = {
"running": InstallStatus.RUNNING,
"done": InstallStatus.DONE,
"failed": InstallStatus.FAILED,
}
def updates_dir() -> Path:
return Path(settings.CHATBALLS_UPDATES_DIR)
def check_for_updates(*, force: bool = False) -> UpdateState:
"""Спросить канал релизов, если пора (или если попросили явно)."""
state = UpdateState.load()
interval = timedelta(seconds=settings.CHATBALLS_UPDATE_CHECK_INTERVAL_SECONDS)
if not force and state.checked_at and timezone.now() - state.checked_at < interval:
return state
try:
release = fetch_latest_release()
except ReleaseChannelError as error:
state.check_error = str(error)[:500]
state.checked_at = timezone.now()
state.save(update_fields=["check_error", "checked_at"])
logger.warning("Update check failed: %s", error)
return state
state.latest_version = release.version
state.latest_name = release.name
state.latest_notes = release.notes
state.latest_published_at = release.published_at
state.latest_compose_url = release.compose_url
state.latest_page_url = release.page_url
state.check_error = ""
state.checked_at = timezone.now()
state.save()
return state
def update_available(state: UpdateState) -> bool:
return bool(state.latest_version) and is_newer(state.latest_version, settings.CHATBALLS_VERSION)
def updater_online() -> bool:
"""Жив ли сервис updater: он отмечается в томе каждые несколько секунд."""
path = updates_dir() / HEARTBEAT_FILE
try:
age = timezone.now().timestamp() - path.stat().st_mtime
except OSError:
return False
return age < HEARTBEAT_STALE_SECONDS
def sync_install_status(state: UpdateState) -> UpdateState:
"""Перенести статус, который пишет updater, в состояние приложения."""
if state.install_status not in {InstallStatus.REQUESTED, InstallStatus.RUNNING}:
return state
path = updates_dir() / STATUS_FILE
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return state
if str(payload.get("version", "")) != state.install_version:
return state
status = _STATUS_MAP.get(str(payload.get("status", "")).lower())
if status is None:
return state
message = str(payload.get("message", ""))[:1000]
if status != state.install_status or message != state.install_message:
state.install_status = status
state.install_message = message
state.install_updated_at = timezone.now()
state.save(update_fields=["install_status", "install_message", "install_updated_at"])
return state
class InstallNotPossible(Exception):
def __init__(self, message: str, *, code: str) -> None:
super().__init__(message)
self.code = code
def request_install(*, actor, state: UpdateState | None = None) -> UpdateState:
"""Положить запрос на установку последней версии в том updater'а."""
from chatballs.i18n import t
state = sync_install_status(state or UpdateState.load())
if not update_available(state):
raise InstallNotPossible(t("updates.nothing_to_install"), code="nothing_to_install")
if not updater_online():
raise InstallNotPossible(t("updates.updater_offline"), code="updater_offline")
if state.install_status in {InstallStatus.REQUESTED, InstallStatus.RUNNING}:
raise InstallNotPossible(t("updates.install_in_progress"), code="install_in_progress")
directory = updates_dir()
directory.mkdir(parents=True, exist_ok=True)
request_path = directory / REQUEST_FILE
now = timezone.now()
request_path.write_text(
json.dumps(
{
"version": state.latest_version,
"compose_url": state.latest_compose_url,
"requested_at": now.isoformat(),
"requested_by": getattr(actor, "email", ""),
}
),
encoding="utf-8",
)
# Прежний статус относится к прошлой установке: убрать, чтобы не спутать.
try:
(directory / STATUS_FILE).unlink()
except OSError:
pass
state.install_version = state.latest_version
state.install_status = InstallStatus.REQUESTED
state.install_message = ""
state.install_requested_at = now
state.install_updated_at = now
state.install_requested_by = actor if getattr(actor, "pk", None) else None
state.save()
return state
def update_payload(state: UpdateState) -> dict[str, object]:
return {
"currentVersion": settings.CHATBALLS_VERSION,
"latestVersion": state.latest_version or None,
"latestName": state.latest_name,
"latestNotes": state.latest_notes,
"latestPublishedAt": state.latest_published_at.isoformat() if state.latest_published_at else None,
"latestPageUrl": state.latest_page_url,
"available": update_available(state),
"checkedAt": state.checked_at.isoformat() if state.checked_at else None,
"checkError": state.check_error,
"updaterOnline": updater_online(),
"install": {
"version": state.install_version or None,
"status": state.install_status,
"message": state.install_message,
"requestedAt": state.install_requested_at.isoformat() if state.install_requested_at else None,
"updatedAt": state.install_updated_at.isoformat() if state.install_updated_at else None,
},
}
Loaded 100 of 167 files, more files were not shown because too many files have changed in this diff. Show more