Compare commits

..
15 Commits
Author SHA1 Message Date
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
117 changed files with 3084 additions and 332 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"
+9 -9
View File
@@ -44,7 +44,7 @@ jobs:
images:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Версия релиза
id: version
@@ -57,9 +57,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 }}
@@ -81,7 +81,7 @@ jobs:
- 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
@@ -94,7 +94,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 +109,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 +124,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
@@ -180,7 +180,7 @@ jobs:
cat dist/compose.yaml
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
with:
name: release-compose
path: |
@@ -189,7 +189,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
+2 -2
View File
@@ -311,13 +311,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
+2 -2
View File
@@ -311,13 +311,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.4.0
@@ -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,10 @@ 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
logger = logging.getLogger(__name__)
@@ -28,7 +28,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
)
+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:
+8 -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,9 @@ 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_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",
@@ -327,6 +332,7 @@ MESSAGES: dict[str, object] = {
"identity.invalid_totp_code": "Invalid TOTP code",
"identity.invitation_email_mismatch": "Invitation email does not match the account",
"identity.invitation_invalid": "Invitation is invalid or has expired",
"identity.invitation_account_exists": "An account with this address already exists. Sign in with it",
"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 +355,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",
+8 -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,9 @@ 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_identity_employee_groups_changed": "Изменены группы сотрудника",
"audit.action_identity_employee_password_reset": "Сброшен пароль сотрудника",
"audit.action_identity_employee_privileged_action_denied": "Отказано в привилегированном действии",
@@ -331,6 +336,7 @@ MESSAGES: dict[str, object] = {
"identity.invalid_totp_code": "Неверный код",
"identity.invitation_email_mismatch": "Приглашение выписано на другой адрес",
"identity.invitation_invalid": "Приглашение недействительно или истекло",
"identity.invitation_account_exists": "Учётная запись с этим адресом уже есть — войдите под ней",
"identity.role_conflict": "У пользователя уже другая роль в этой организации",
"identity.token_required": "Нужен токен",
"identity.totp_challenge_inactive": "Проверка кода уже неактуальна — войдите заново",
@@ -353,7 +359,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
@@ -85,6 +86,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 +105,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,9 @@ 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",
"identity.employee_updated": "audit.action_identity_employee_updated",
"identity.employee_blocked": "audit.action_identity_employee_blocked",
"identity.employee_unblocked": "audit.action_identity_employee_unblocked",
@@ -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),
}
)
@@ -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,28 @@
"""Настройки установки: путь без организации в адресе.
Адрес, почта, 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
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",
),
]
@@ -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,8 +23,17 @@ from chatballs.identity.models import (
OrganizationMembership,
OrganizationStatus,
)
from chatballs.tenancy.context import TenantContext
from chatballs.tenancy.database import tenant_atomic
# Приглашение существующего пользователя в организацию: письмо отправляет
# воркер по этому событию (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:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
@@ -54,15 +68,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,10 +90,119 @@ 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:
if not token:
return None
@@ -89,10 +216,11 @@ def pending_invitation_for_token(token: str) -> OrganizationInvitation | None:
@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 +238,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 +260,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
@@ -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="")
+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,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})
@@ -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):
+45 -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())
@@ -84,3 +84,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)]
+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()
)
+62 -1
View File
@@ -1,3 +1,4 @@
from django.core.exceptions import ValidationError
from django.db import DatabaseError, connection, transaction
from django.test import TransactionTestCase
@@ -162,7 +163,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 +429,23 @@ class RowLevelSecurityTests(TransactionTestCase):
def test_app_role_reads_ingress_directory_and_cannot_add_organizations(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 закрыт политикой bootstrap.
with self.assertRaises(DatabaseError), transaction.atomic():
self._set_role("chatballs_runtime_app")
Organization.objects.create(name="Third", slug="rls-third")
def test_platform_role_can_only_use_ingress_directory(self) -> None:
with self.assertRaises(DatabaseError), transaction.atomic():
@@ -464,6 +484,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)
+5 -7
View File
@@ -15,7 +15,6 @@ from chatballs.conversations.attachment_views import (
from chatballs.conversations.models import Message, MessageKind
from chatballs.conversations.voice_views import ALLOWED_AUDIO_TYPES, MAX_VOICE_BYTES
from chatballs.i18n import t
from chatballs.identity.models import Organization
from chatballs.integrations.features import voice_messages_allowed
from chatballs.integrations.models import IntegrationStatus
from chatballs.tenancy.context import TenantContext
@@ -25,6 +24,7 @@ from chatballs.tenancy.ingress import (
web_session_route,
web_widget_route,
)
from chatballs.tenancy.lookup import load_organization
from chatballs.webchat import services
from chatballs.webchat.api_inputs import host_origin, session_token
from chatballs.webchat.loader import LOADER_JS
@@ -58,9 +58,8 @@ def _resolved_web_widget(widget_key: str, channel_code: str = ""):
if route is None:
yield None, None
return
try:
organization = Organization.objects.get(pk=route.organization_id)
except Organization.DoesNotExist:
organization = load_organization(route.organization_id)
if organization is None:
yield None, None
return
context = TenantContext.for_resource(organization)
@@ -96,9 +95,8 @@ def _resolved_web_session(request: Request):
if route is None:
yield None, None
return
try:
organization = Organization.objects.get(pk=route.organization_id)
except Organization.DoesNotExist:
organization = load_organization(route.organization_id)
if organization is None:
yield None, None
return
context = TenantContext.for_resource(organization)
@@ -2,7 +2,7 @@ import os
from django.core.exceptions import ImproperlyConfigured
from chatballs_backend.settings_env import env_secret
from chatballs_backend.settings_env import env_bool, env_secret
def _credentials() -> tuple[dict[str, str], dict[str, str]]:
@@ -16,16 +16,19 @@ def _credentials() -> tuple[dict[str, str], dict[str, str]]:
"POSTGRES_MIGRATION_USER", "chatballs_migration"
),
}
# Пароли ролей генерирует первый старт стека в том с секретами; человек их
# не вводит и не хранит. Переменные окружения остаются переопределением.
fallback = env_secret("POSTGRES_PASSWORD", "postgres_password", "chatballs")
# Пароли ролей генерирует первый старт стека в томах с секретами; человек их
# не вводит и не хранит. Пароли platform и migration лежат в своих томах
# (подкаталоги platform/ и schema/), которые монтируются только процессам с
# этими ролями; у остальных файла нет и остаётся default. Переменные
# окружения остаются переопределением.
fallback = env_secret("POSTGRES_PASSWORD", "schema/postgres_password", "chatballs")
passwords = {
"app": env_secret("POSTGRES_APP_PASSWORD", "postgres_app_password", fallback),
"platform": env_secret(
"POSTGRES_PLATFORM_PASSWORD", "postgres_platform_password", fallback
"POSTGRES_PLATFORM_PASSWORD", "platform/postgres_platform_password", fallback
),
"migration": env_secret(
"POSTGRES_MIGRATION_PASSWORD", "postgres_migration_password", fallback
"POSTGRES_MIGRATION_PASSWORD", "schema/postgres_migration_password", fallback
),
}
return users, passwords
@@ -75,10 +78,13 @@ def build_databases(*, debug: bool, testing: bool) -> dict[str, dict]:
"OPTIONS": {"pool": dict(pool)} if pool else {},
}
databases = {
"default": config("migration" if testing else role),
"platform": config("platform"),
}
databases = {"default": config("migration" if testing else role)}
# Алиас platform поднимается только там, где он нужен: в платформенной
# поверхности и в воркере (CHATBALLS_DB_PLATFORM_ALIAS=1 — он захватывает
# outbox всех организаций). Остальные процессы пароль этой роли не читают;
# обращение к алиасу там упадёт сразу, а не откроет обход изоляции.
if testing or role == "platform" or env_bool("CHATBALLS_DB_PLATFORM_ALIAS"):
databases["platform"] = config("platform")
if testing:
# Тесты создают свою БД и подключаются владельцем кластера. Его пароль
# приходит оттуда же, откуда у остальных ролей: файл секрета инстанса,
@@ -87,7 +93,7 @@ def build_databases(*, debug: bool, testing: bool) -> dict[str, dict]:
"POSTGRES_USER", "chatballs_bootstrap"
)
databases["default"]["PASSWORD"] = env_secret(
"POSTGRES_PASSWORD", "postgres_password", "chatballs"
"POSTGRES_PASSWORD", "schema/postgres_password", "chatballs"
)
databases["platform"]["TEST"] = {"MIRROR": "default"}
return databases
@@ -13,6 +13,7 @@ urlpatterns = [
),
path("api/v1/auth/", include("chatballs.identity.auth_urls")),
path("api/v1/setup/", include("chatballs.identity.setup_urls")),
path("api/v1/instance/", include("chatballs.identity.instance_urls")),
path(
"api/v1/demo-media/avatars/<str:name>",
DemoMediaView.as_view(),
+43 -4
View File
@@ -12,7 +12,7 @@ import { api, setActiveOrganization } from "./api/client";
import { fetchAgentDirectory } from "./features/agents/model";
import { canAccess, defaultRoute, isManager } from "./auth/access";
import { activateOrganization, clearOrganizationPreference } from "./auth/session";
import { AuthChangePassword, AuthLogin, AuthPasswordRecovery, AuthResetPassword, AuthSetup, AuthTotpCode, AuthTotpSetup } from "./features/auth/AuthScreens";
import { AuthChangePassword, AuthJoin, AuthJoinGuest, AuthLogin, AuthPasswordRecovery, AuthResetPassword, AuthSetup, AuthTotpCode, AuthTotpSetup } from "./features/auth/AuthScreens";
import { Shell } from "./layout/Shell";
import { pathFromRoute, routeFromPath } from "./router";
import { ErrorScreen, LoadingScreen, PermissionScreen } from "./shared/ui";
@@ -42,6 +42,14 @@ export function App() {
const [totpChallenge, setTotpChallenge] = useState<AuthChallenge | null>(null);
const [recovering, setRecovering] = useState(false);
const [resetting, setResetting] = useState(() => window.location.pathname === "/reset-password");
// Ссылка из письма-приглашения (/join?token=…): токен запоминается до входа
// и принимается, как только известна учётная запись.
const [joinToken, setJoinToken] = useState<string | null>(
() => (window.location.pathname === "/join" ? new URLSearchParams(window.location.search).get("token") : null),
);
// До входа ссылка ведёт либо на форму создания пароля (учётной записи ещё
// нет), либо на обычный вход — это решает предпросмотр приглашения.
const [joinNeedsLogin, setJoinNeedsLogin] = useState(false);
// Мастер первого запуска: пока в инстансе нет организации, вместо входа — форма создания.
const [needsSetup, setNeedsSetup] = useState(false);
const [data, setData] = useState<AppData>({ groups: [], agents: [] });
@@ -90,7 +98,8 @@ export function App() {
if (!payload.authenticated) acceptServerLanguage(payload.language);
if (payload.authenticated && payload.user) {
const activeUser = useIdentity(payload.user, initialRoute.organizationPublicId);
if (activeUser && !initialRoute.organizationPublicId) {
// Адрес без организации или с чужой: подставляем ту, что открылась.
if (activeUser && activeUser.organizationPublicId !== initialRoute.organizationPublicId) {
const nextPath = pathFromRoute(
initialRoute.route,
initialRoute.employeeId || initialRoute.agentId || initialRoute.knowledgeId || initialRoute.clientId || initialRoute.channelId || initialRoute.supportPortalId || initialRoute.portalSettingsSection || initialRoute.settingsSection,
@@ -133,6 +142,32 @@ export function App() {
useIdentity(nextIdentity, organizationPublicId);
}, [organizationPublicId, useIdentity]);
// Переключатель в сайдбаре: другая организация открывается со своего
// стартового экрана, Shell пересобирается по key — состояние страниц
// прежней организации в новую не переезжает.
const switchOrganization = useCallback((nextOrganizationId: string) => {
if (!identity) return;
const activeUser = useIdentity(identity, nextOrganizationId);
if (activeUser) {
navigate(defaultRoute(activeUser), null, false, activeUser.organizationPublicId);
}
}, [identity, navigate, useIdentity]);
const finishJoin = useCallback((nextIdentity: AuthenticatedUser, organizationId: string) => {
setJoinToken(null);
const activeUser = useIdentity(nextIdentity, organizationId);
if (activeUser) {
navigate(defaultRoute(activeUser), null, true, activeUser.organizationPublicId);
}
}, [navigate, useIdentity]);
const joinUseLogin = useCallback(() => setJoinNeedsLogin(true), []);
const cancelJoin = useCallback(() => {
setJoinToken(null);
if (user) navigate(defaultRoute(user), null, true, user.organizationPublicId);
}, [navigate, user]);
async function logout() {
await api("/api/v1/auth/logout/", { method: "POST" }).catch(() => undefined);
setIdentity(null);
@@ -160,13 +195,17 @@ export function App() {
{totpChallenge ? (
<AuthTotpCode challenge={totpChallenge} onVerified={(nextUser) => { setTotpChallenge(null); landAfterAuth(nextUser); }} />
) : !identity ? (
needsSetup ? (
joinToken && !joinNeedsLogin && !needsSetup ? (
<AuthJoinGuest token={joinToken} onUseLogin={joinUseLogin} onRegistered={finishJoin} />
) : needsSetup ? (
<AuthSetup onDone={(nextUser) => { setNeedsSetup(false); landAfterAuth(nextUser); }} />
) : recovering ? (
<AuthPasswordRecovery onBackToLogin={() => setRecovering(false)} />
) : (
<AuthLogin onLogin={landAfterAuth} onTotpChallenge={setTotpChallenge} onRecover={() => setRecovering(true)} />
)
) : joinToken ? (
<AuthJoin token={joinToken} onAccepted={finishJoin} onBack={cancelJoin} />
) : !user ? (
<PermissionScreen onReturn={logout} />
) : user.mustChangePassword ? (
@@ -178,7 +217,7 @@ export function App() {
) : !canAccess(user, navigation.route) ? (
<PermissionScreen onReturn={() => navigate(defaultRoute(user), null, true)} />
) : (
<Shell route={navigation.route} setRoute={(nextRoute) => navigate(nextRoute)} selectedEmployeeId={navigation.selectedEmployeeId} selectedAgentId={navigation.selectedAgentId} selectedKnowledgeId={navigation.selectedKnowledgeId} selectedConversationId={navigation.selectedConversationId} selectedClientId={navigation.selectedClientId} openClientRoute={(clientId) => navigate("salesClientDetail", clientId)} selectedChannelId={navigation.selectedChannelId} openChannelRoute={(channelId) => navigate("agentDetail", channelId)} selectedSupportPortalId={navigation.selectedSupportPortalId} openSupportPortalRoute={(portalId) => navigate("supportPortalDetail", portalId)} portalSettingsSection={navigation.selectedPortalSection} openPortalSettingsRoute={(portalId, section) => navigate("supportPortalSettings", `${portalId}/${section ?? ""}`)} settingsSection={navigation.selectedSettingsSection} openSettingsRoute={(section) => navigate("settings", section)} openEmployeeRoute={(employeeId) => navigate("employeeDetail", employeeId)} openAgentRoute={(agentId) => navigate("agentDetail", agentId)} openKnowledgeRoute={(knowledgeId) => navigate("knowledgeDetail", knowledgeId)} openKnowledgeEditorRoute={(knowledgeId) => (knowledgeId === null ? navigate("knowledgeCreate") : navigate("knowledgeEdit", knowledgeId))} openConversationRoute={(conversationId) => navigate("chat", conversationId)} user={user} data={data} reload={loadData} onUserUpdated={refreshIdentity} onLogout={logout} />
<Shell key={user.organizationPublicId} route={navigation.route} setRoute={(nextRoute) => navigate(nextRoute)} selectedEmployeeId={navigation.selectedEmployeeId} selectedAgentId={navigation.selectedAgentId} selectedKnowledgeId={navigation.selectedKnowledgeId} selectedConversationId={navigation.selectedConversationId} selectedClientId={navigation.selectedClientId} openClientRoute={(clientId) => navigate("salesClientDetail", clientId)} selectedChannelId={navigation.selectedChannelId} openChannelRoute={(channelId) => navigate("agentDetail", channelId)} selectedSupportPortalId={navigation.selectedSupportPortalId} openSupportPortalRoute={(portalId) => navigate("supportPortalDetail", portalId)} portalSettingsSection={navigation.selectedPortalSection} openPortalSettingsRoute={(portalId, section) => navigate("supportPortalSettings", `${portalId}/${section ?? ""}`)} settingsSection={navigation.selectedSettingsSection} openSettingsRoute={(section) => navigate("settings", section)} openEmployeeRoute={(employeeId) => navigate("employeeDetail", employeeId)} openAgentRoute={(agentId) => navigate("agentDetail", agentId)} openKnowledgeRoute={(knowledgeId) => navigate("knowledgeDetail", knowledgeId)} openKnowledgeEditorRoute={(knowledgeId) => (knowledgeId === null ? navigate("knowledgeCreate") : navigate("knowledgeEdit", knowledgeId))} openConversationRoute={(conversationId) => navigate("chat", conversationId)} user={user} data={data} reload={loadData} onUserUpdated={refreshIdentity} onLogout={logout} onSwitchOrganization={switchOrganization} />
)}
</ConfigProvider>
);
+1
View File
@@ -12,6 +12,7 @@ function userWith(role: Role, capabilities: string[] = []): SessionUser {
totpEnabled: false,
totpLastUsedAt: null,
deliveryMode: "CLOUD",
isInstanceAdmin: false,
uiTheme: "SYSTEM",
uiAccent: "",
uiLanguage: "",
+30 -6
View File
@@ -2,19 +2,43 @@ import type { AuthenticatedUser, SessionUser } from "../types";
const preferenceKey = "chatballs.organizationPublicId";
function storedPreference(): string | null {
try {
return window.localStorage.getItem(preferenceKey);
} catch {
return null;
}
}
function rememberPreference(publicId: string): void {
try {
window.localStorage.setItem(preferenceKey, publicId);
} catch {
// Хранилище недоступно (приватный режим, запрет сайта) — адрес всё равно
// несёт организацию, а без него откроется первая по списку.
}
}
/** Выбрать организацию сессии: из адреса, иначе последнюю открытую, иначе
* первую в списке членств (сервер отдаёт их по имени). Человек с несколькими
* организациями не упирается в «нет доступа» — он входит в одну из своих и
* меняет её переключателем в сайдбаре (дизайн-базлайн v2, A1). */
export function activateOrganization(
identity: AuthenticatedUser,
requestedPublicId: string | null,
): SessionUser | null {
const preferred = requestedPublicId || window.sessionStorage.getItem(preferenceKey);
const membership =
identity.memberships.find((item) => item.organizationPublicId === preferred) ||
(identity.memberships.length === 1 ? identity.memberships[0] : null);
const byId = (publicId: string | null) =>
publicId ? identity.memberships.find((item) => item.organizationPublicId === publicId) : undefined;
const membership = byId(requestedPublicId) || byId(storedPreference()) || identity.memberships[0];
if (!membership) return null;
window.sessionStorage.setItem(preferenceKey, membership.organizationPublicId);
rememberPreference(membership.organizationPublicId);
return { ...identity, ...membership };
}
export function clearOrganizationPreference(): void {
window.sessionStorage.removeItem(preferenceKey);
try {
window.localStorage.removeItem(preferenceKey);
} catch {
// См. rememberPreference.
}
}
@@ -38,7 +38,7 @@ export function OrganizationLogoField({
<input
ref={input}
type="file"
accept="image/png,image/jpeg,image/webp"
accept="image/png,image/jpeg,image/webp,image/svg+xml"
hidden
onChange={(event) => {
const file = event.target.files?.[0];
@@ -505,7 +505,15 @@
.knowledge-table .row-actions { text-align: right; }
/* --- Подвал таблицы --- */
/* --- Пустое состояние (кадр KB1): блок по центру рабочей области --- */
.knowledge-empty {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
padding: 40px;
}
.knowledge-empty > div {
width: 480px;
@@ -0,0 +1,61 @@
import { useEffect, useState } from "react";
import { api } from "../../api/client";
import { Icon } from "../../shared/icons";
import { AuthFrame } from "./AuthFrame";
import { t } from "../../i18n";
import type { AuthenticatedUser } from "../../types";
// Приглашение в организацию по ссылке из письма (/join?token=…): человек уже
// вошёл под своей учётной записью, приглашение принимается само, и приложение
// открывается в новой организации. Отказ сервера показывается его же словами.
type Status = "accepting" | "failed";
export function AuthJoin({ token, onAccepted, onBack }: {
token: string;
onAccepted: (user: AuthenticatedUser, organizationPublicId: string) => void;
onBack: () => void;
}) {
const [status, setStatus] = useState<Status>("accepting");
const [detail, setDetail] = useState("");
useEffect(() => {
let active = true;
api<{ user: AuthenticatedUser; organizationPublicId: string }>("/api/v1/auth/invitations/accept/", {
method: "POST",
body: JSON.stringify({ token }),
})
.then((payload) => { if (active) onAccepted(payload.user, payload.organizationPublicId); })
.catch((error) => {
if (!active) return;
setDetail(error instanceof Error ? error.message : "");
setStatus("failed");
});
return () => { active = false; };
}, [token, onAccepted]);
if (status === "accepting") {
return (
<AuthFrame title={t("auth.invitation_title")} subtitle={t("admin.one_moment")} logo="shield">
<div className="auth-card"><p className="auth-hint">{t("auth.invitation_accepting")}</p></div>
</AuthFrame>
);
}
const backLink = (
<button type="button" className="auth-back-login" onClick={onBack}><Icon name="arrow" size={14} />{t("profile.go_start_page")}</button>
);
return (
<AuthFrame title={t("auth.invitation_title")} subtitle={t("auth.invitation_not_accepted")} logo="shield" note={backLink}>
<div className="auth-card">
<div className="auth-recovery-sent">
<div className="auth-recovery-sent-icon warning"><Icon name="warning" size={26} /></div>
<h3>{t("auth.invitation_not_accepted")}</h3>
{detail && <p>{detail}</p>}
</div>
</div>
</AuthFrame>
);
}
@@ -0,0 +1,130 @@
import { type FormEvent, useEffect, useState } from "react";
import { api, ApiError } from "../../api/client";
import { Icon } from "../../shared/icons";
import { Button } from "../../shared/ui-controls";
import { AuthField } from "./AuthField";
import { AuthFrame } from "./AuthFrame";
import { passwordIsValid, passwordLabel, passwordScore } from "./password";
import { t } from "../../i18n";
import type { AuthenticatedUser } from "../../types";
// Ссылка-приглашение без входа (/join?token=…). Сервер говорит, куда зовут и
// есть ли учётная запись: существующая идёт на обычный вход, новая задаёт имя
// и пароль прямо здесь — теми же полями, что в мастере первого запуска, — и
// сразу оказывается в организации.
type Preview = { valid: boolean; email?: string; organizationName?: string; accountExists?: boolean };
type Status = "checking" | "invalid" | "register";
type FieldErrors = { fullName?: string; password?: string };
export function AuthJoinGuest({ token, onUseLogin, onRegistered }: {
token: string;
onUseLogin: () => void;
onRegistered: (user: AuthenticatedUser, organizationPublicId: string) => void;
}) {
const [status, setStatus] = useState<Status>("checking");
const [preview, setPreview] = useState<Preview | null>(null);
const [fullName, setFullName] = useState("");
const [password, setPassword] = useState("");
const [show, setShow] = useState(false);
const [errors, setErrors] = useState<FieldErrors>({});
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
let active = true;
api<Preview>(`/api/v1/auth/invitations/preview/?token=${encodeURIComponent(token)}`)
.then((payload) => {
if (!active) return;
if (!payload.valid) { setStatus("invalid"); return; }
if (payload.accountExists) { onUseLogin(); return; }
setPreview(payload);
setStatus("register");
})
.catch(() => { if (active) setStatus("invalid"); });
return () => { active = false; };
}, [token, onUseLogin]);
const score = passwordScore(password);
const valid = fullName.trim() !== "" && passwordIsValid(password, false);
async function submit(event: FormEvent) {
event.preventDefault();
if (!valid || submitting) return;
setSubmitting(true);
setError("");
setErrors({});
try {
const payload = await api<{ user: AuthenticatedUser; organizationPublicId: string }>("/api/v1/auth/invitations/register/", {
method: "POST",
body: JSON.stringify({ token, fullName, password }),
});
onRegistered(payload.user, payload.organizationPublicId);
} catch (requestError) {
if (requestError instanceof ApiError) {
const fieldErrors = (requestError.payload as { errors?: FieldErrors }).errors;
if (fieldErrors) setErrors(fieldErrors);
setError(requestError.message);
} else {
setError(t("auth.invitation_not_accepted"));
}
} finally {
setSubmitting(false);
}
}
if (status === "checking") {
return (
<AuthFrame title={t("auth.invitation_title")} subtitle={t("admin.one_moment")} logo="shield">
<div className="auth-card"><p className="auth-hint">{t("admin.checking_link")}</p></div>
</AuthFrame>
);
}
if (status === "invalid") {
return (
<AuthFrame title={t("auth.invitation_title")} subtitle={t("auth.invitation_not_accepted")} logo="shield">
<div className="auth-card">
<div className="auth-recovery-sent">
<div className="auth-recovery-sent-icon warning"><Icon name="warning" size={26} /></div>
<h3>{t("admin.link_does_not_work")}</h3>
<p>{t("auth.invitation_invalid")}</p>
</div>
</div>
</AuthFrame>
);
}
return (
<AuthFrame
title={t("auth.invitation_title")}
subtitle={t("auth.join_subtitle", { organization: preview?.organizationName ?? "", email: preview?.email ?? "" })}
logo="shield"
width={420}
>
<form className="auth-card" onSubmit={submit}>
{error && <div className="auth-error"><span className="auth-error-dot">!</span><span>{error}</span></div>}
<label className="field-label">{t("admin.name")}</label>
<AuthField icon="user" value={fullName} onChange={setFullName} placeholder={t("admin.elena_kuznetsova")} error={Boolean(errors.fullName)} />
<label className="field-label">{t("common.password")}</label>
<AuthField icon="lock" value={password} onChange={setPassword} placeholder={t("common.at_least_10_characters")} type={show ? "text" : "password"} error={Boolean(errors.password)}>
<button type="button" onClick={() => setShow((value) => !value)} aria-label={show ? t("admin.hide_password") : t("admin.show_password")}>
<Icon name={show ? "eyeOff" : "eye"} size={17} />
</button>
</AuthField>
<div className={`password-strength score-${score}`}>
<div>{[0, 1, 2, 3].map((item) => <span className={item < score ? "active" : ""} key={item} />)}</div>
<p>{passwordLabel(score)}</p>
</div>
<div className="password-requirements">
<strong>{t("common.password_requirements")}</strong>
<span className={password.length >= 10 ? "done" : ""}>{t("common.at_least_10_characters_long")}</span>
<span className={/\d/.test(password) ? "done" : ""}>{t("common.contains_digit")}</span>
<span className={/[A-Za-zА-Яа-я]/.test(password) && /[^A-Za-zА-Яа-я0-9]/.test(password) ? "done" : ""}>{t("common.letters_special_character")}</span>
</div>
<Button className="auth-submit" type="submit" variant="primary" disabled={!valid || submitting}>{t("auth.join_accept")}</Button>
</form>
</AuthFrame>
);
}
@@ -1,4 +1,6 @@
export { AuthChangePassword } from "./AuthChangePassword";
export { AuthJoin } from "./AuthJoin";
export { AuthJoinGuest } from "./AuthJoinGuest";
export { AuthLogin } from "./AuthLogin";
export { AuthPasswordRecovery } from "./AuthPasswordRecovery";
export { AuthResetPassword } from "./AuthResetPassword";
@@ -0,0 +1,64 @@
import { Dropdown } from "antd";
import { useState } from "react";
import type { EmployeeGroup, EmployeeInvitation } from "../../types";
import { Icon } from "../../shared/icons";
import { Avatar } from "../../shared/ui";
import { groupColorOf } from "../conversations/model";
import { formatDate, invitationStatusBadge, roleAccessLabel, roleBadge } from "./model";
import { t } from "../../i18n";
// Строка ожидающего приглашения (кадры E1/E2, статус «Приглашён»): та же
// разметка, что у сотрудника, — роль, должность и группы уже известны, они
// придут вместе с членством после принятия. Карточки у приглашения нет,
// поэтому строка не открывается; меню — отправить ещё раз или отозвать.
export function EmployeeInvitationRow({ invitation, groups, canManage, onResend, onRevoke }: {
invitation: EmployeeInvitation;
groups: EmployeeGroup[];
canManage: boolean;
onResend: (invitation: EmployeeInvitation) => void;
onRevoke: (invitation: EmployeeInvitation) => void;
}) {
const [menuOpen, setMenuOpen] = useState(false);
const role = roleBadge(invitation.role);
const status = invitationStatusBadge();
const act = (run: () => void) => { setMenuOpen(false); run(); };
const menuItems = [
{ key: "resend", disabled: !canManage, label: <button type="button" onClick={() => act(() => onResend(invitation))}><Icon name="mail" size={15} />{t("admin.resend_invitation")}</button> },
{ type: "divider" as const },
{ key: "revoke", disabled: !canManage, label: <button className="danger" type="button" onClick={() => act(() => onRevoke(invitation))}><Icon name="xCircle" size={15} />{t("admin.revoke_invitation")}</button> },
];
return (
<div className="employees-row is-invited">
<div className="employees-person">
<Avatar employee={{ ...invitation, isActive: true, isBlocked: false, mustChangePassword: true, totpRequired: false, totpEnabled: false }} />
<span>
<strong>{invitation.fullName || invitation.email}</strong>
<small>{invitation.email}</small>
</span>
</div>
<b className="employees-badge" style={{ background: role.bg, color: role.color }}>{role.text}</b>
<span className="employees-position">{invitation.positionTitle}</span>
<div className="employees-groups">
{invitation.groups.length === 0
? <span className="employees-nogroup">{t("common.no_group")}</span>
: invitation.groups.map((group) => (
<span className="employees-group" key={group.id}>
<i style={{ background: groupColorOf(group.id, groups.find((item) => item.id === group.id)?.color) }} />
{group.name}
</span>
))}
</div>
<span className={`employees-access ${invitation.role === "EMPLOYEE" ? "is-limited" : ""}`}>{roleAccessLabel(invitation)}</span>
<b className="employees-badge has-dot" style={{ background: status.bg, color: status.color }}><i />{status.text}</b>
<span className="employees-login">{t("admin.invitation_valid_until", { date: formatDate(invitation.expiresAt) })}</span>
<Dropdown menu={{ items: menuItems }} open={menuOpen} onOpenChange={setMenuOpen} trigger={["click"]} overlayClassName="app-dropdown is-employee-menu">
<button className={`employees-row-menu ${menuOpen ? "is-open" : ""}`} type="button" aria-label={t("common.actions_for", { name: invitation.fullName || invitation.email })} title={t("common.actions")} onClick={(event) => event.stopPropagation()}>
<Icon name="more" size={16} />
</button>
</Dropdown>
</div>
);
}
@@ -1,9 +1,10 @@
import { Dropdown } from "antd";
import type { Employee, EmployeeGroup } from "../../types";
import type { Employee, EmployeeGroup, EmployeeInvitation } from "../../types";
import { Icon } from "../../shared/icons";
import { Avatar, EmptyState } from "../../shared/ui";
import { groupColorOf } from "../conversations/model";
import { EmployeeInvitationRow } from "./EmployeeInvitationRow";
import { employeeAvatarColor, formatLastLogin, roleAccessLabel, roleBadge, statusBadge } from "./model";
import { t } from "../../i18n";
@@ -12,21 +13,30 @@ import { t } from "../../i18n";
export function EmployeeTable({
employees,
invitations = [],
canManageInvitations = false,
groups,
menuId,
onBlock,
onResetPassword,
onTerminateSessions,
onResendInvitation,
onRevokeInvitation,
openEmployee,
setMenuId,
total,
}: {
employees: Employee[];
// Ожидающие приглашения — строками со статусом «Приглашён» перед сотрудниками.
invitations?: EmployeeInvitation[];
canManageInvitations?: boolean;
groups: EmployeeGroup[];
menuId: number | null;
onBlock: (employee: Employee) => void;
onResetPassword: (employee: Employee) => void;
onTerminateSessions: (employee: Employee) => void;
onResendInvitation?: (invitation: EmployeeInvitation) => void;
onRevokeInvitation?: (invitation: EmployeeInvitation) => void;
openEmployee: (employee: Employee) => void;
setMenuId: (id: number | null) => void;
total: number;
@@ -43,6 +53,16 @@ export function EmployeeTable({
<span>{t("admin.last_sign")}</span>
<span />
</div>
{invitations.map((invitation) => (
<EmployeeInvitationRow
invitation={invitation}
groups={groups}
canManage={canManageInvitations}
onResend={(item) => onResendInvitation?.(item)}
onRevoke={(item) => onRevokeInvitation?.(item)}
key={`invitation-${invitation.id}`}
/>
))}
{employees.map((employee) => (
<EmployeeRow
employee={employee}
@@ -56,7 +76,7 @@ export function EmployeeTable({
key={employee.id}
/>
))}
{!employees.length && <EmptyState title={t("admin.no_operators_found")} />}
{!employees.length && !invitations.length && <EmptyState title={t("admin.no_operators_found")} />}
<div className="employees-foot">
<small>{t("common.shown_of", { shown: employees.length, total })}</small>
<small>{t("admin.group_decides_which_conversations_visible_2")}</small>
@@ -11,7 +11,7 @@ import { EmployeeCreateDrawer } from "./EmployeeCreateDrawer";
import { EmployeePasswordDialog } from "./EmployeePasswordDialog";
import { EmployeeTable } from "./EmployeeTable";
import { EmployeesFilters } from "./EmployeesFilters";
import { blockEmployee, fetchEmployees, resetEmployeePassword, terminateEmployeeSessions, type IssuedPassword } from "./api";
import { blockEmployee, fetchEmployees, resendInvitation, resetEmployeePassword, revokeInvitation, terminateEmployeeSessions, type IssuedPassword } from "./api";
import type { EmployeeRoleFilter } from "./model";
import { t } from "../../i18n";
@@ -82,6 +82,10 @@ export function EmployeesPage({ groups, openEmployee, user }: {
<EmployeeTable
employees={employees.items}
invitations={employees.page === 1 ? employees.payload?.invitations ?? [] : []}
canManageInvitations={canManage}
onResendInvitation={(invitation) => void run(() => resendInvitation(invitation.id))}
onRevokeInvitation={(invitation) => void run(() => revokeInvitation(invitation.id))}
groups={groups}
menuId={menuId}
onBlock={(employee) => void run(() => blockEmployee(employee.id, !employee.isBlocked))}
+22 -5
View File
@@ -1,6 +1,6 @@
import { api } from "../../api/client";
import type { PagedPayload } from "../../shared/usePagedResource";
import type { Employee, Role } from "../../types";
import type { Employee, EmployeeInvitation, Role } from "../../types";
import type { EmployeeRoleFilter } from "./model";
// Действия над сотрудником (дизайн-базлайн v2, кадры E2, E3, E5–E8).
@@ -26,11 +26,16 @@ export type EmployeeCreateInput = {
};
export async function createEmployee(input: EmployeeCreateInput): Promise<IssuedPassword | null> {
const payload = await api<{ employee: Employee; password: string | null }>("/api/v1/employees/operators/", {
// Если адрес уже принадлежит учётной записи из другой организации, сервер
// вместо сотрудника выписывает приглашение: employee и password пусты,
// человек появится в списке после того, как примет его.
const payload = await api<{ employee: Employee | null; password: string | null; invited?: boolean }>("/api/v1/employees/operators/", {
method: "POST",
body: JSON.stringify(input),
});
return payload.password ? { employee: payload.employee, password: payload.password, reason: "created" } : null;
return payload.password && payload.employee
? { employee: payload.employee, password: payload.password, reason: "created" }
: null;
}
export async function resetEmployeePassword(userId: number, mode: PasswordMode): Promise<IssuedPassword | null> {
@@ -56,15 +61,27 @@ export type EmployeeListQuery = {
query: string;
};
// Вместе со страницей сервер отдаёт ожидающие приглашения — их мало и они
// не листаются.
export type EmployeesPayload = PagedPayload<Employee> & { invitations?: EmployeeInvitation[] };
export function fetchEmployees(
{ role, groupId, query }: EmployeeListQuery,
page: number,
): Promise<PagedPayload<Employee>> {
): Promise<EmployeesPayload> {
const params = new URLSearchParams({ page: String(page) });
if (role !== "all") params.set("role", role);
if (groupId !== "all") params.set("group", String(groupId));
if (query.trim()) params.set("q", query.trim());
return api<PagedPayload<Employee>>(`/api/v1/employees/?${params.toString()}`);
return api<EmployeesPayload>(`/api/v1/employees/?${params.toString()}`);
}
export async function resendInvitation(invitationId: number): Promise<void> {
await api(`/api/v1/employees/invitations/${invitationId}/resend/`, { method: "POST" });
}
export async function revokeInvitation(invitationId: number): Promise<void> {
await api(`/api/v1/employees/invitations/${invitationId}/revoke/`, { method: "POST" });
}
/** Кандидаты на владение (кадр E9) — только действующие администраторы.
@@ -59,6 +59,10 @@ export function statusBadge(employee: Employee) {
return STATUS_BADGE[employeeStatusKey(employee)];
}
export function invitationStatusBadge() {
return STATUS_BADGE.invited;
}
export function employeeStatusKey(employee: Employee): EmployeeStatus {
if (employee.isBlocked) return "blocked";
if (employee.mustChangePassword) return "invited";
@@ -83,7 +87,7 @@ export function groupsLabel(employee: Employee) {
return employee.groups.map((group) => group.name).join(", ");
}
export function roleAccessLabel(employee: Employee) {
export function roleAccessLabel(employee: { role: Role }) {
if (employee.role === "OWNER" || employee.role === "ADMIN") return t("common.full_access");
return t("admin.chat_only");
}
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff. Show more