mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
132c684630 | ||
|
|
bfe90e9abd | ||
|
|
5b49904d6a | ||
|
|
cd50cce5a8 | ||
|
|
a4ca7e9373 | ||
|
|
145c44a17d | ||
|
|
ac824e192c | ||
|
|
3e790af6e5 | ||
|
|
1390ccc426 | ||
|
|
2e52bdaea0 | ||
|
|
5bf3d9b572 | ||
|
|
6d94aa1f9f |
No files matched your search
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -19,11 +19,6 @@ from chatballs.integrations.models import IntegrationProvider
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class ClaimError(Exception):
|
||||
|
||||
pass
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Проверка кода уже неактуальна — войдите заново",
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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="")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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"})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -16,8 +16,8 @@ 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__)
|
||||
|
||||
@@ -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)
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
[],
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@@ -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,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)]
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ function userWith(role: Role, capabilities: string[] = []): SessionUser {
|
||||
totpEnabled: false,
|
||||
totpLastUsedAt: null,
|
||||
deliveryMode: "CLOUD",
|
||||
isInstanceAdmin: false,
|
||||
uiTheme: "SYSTEM",
|
||||
uiAccent: "",
|
||||
uiLanguage: "",
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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))}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -29,7 +29,9 @@ type Flag = "voiceMessages" | "audioCalls" | "videoCalls";
|
||||
const BASE = "/api/v1/company/administration/communication/";
|
||||
const COLUMNS: Array<[Flag, string]> = [["voiceMessages", t("settings.voice_messages")], ["audioCalls", t("settings.audio_calls")], ["videoCalls", t("settings.video_calls")]];
|
||||
|
||||
export function CommunicationSettingsCard({ canManage }: { canManage: boolean }) {
|
||||
// Relay — настройка установки, а не организации: менять его может только
|
||||
// администратор установки, поэтому право приходит отдельным флагом.
|
||||
export function CommunicationSettingsCard({ canManage, canManageRelay }: { canManage: boolean; canManageRelay: boolean }) {
|
||||
const [items, setItems] = useState<EntryPoint[] | null>(null);
|
||||
const [busyId, setBusyId] = useState<number | null>(null);
|
||||
const [errorText, setErrorText] = useState("");
|
||||
@@ -85,7 +87,7 @@ export function CommunicationSettingsCard({ canManage }: { canManage: boolean })
|
||||
{items.length > 0 && (
|
||||
<p className="settings-section-note">{t("settings.voice_messages_sent_by_customer")}</p>
|
||||
)}
|
||||
<CallsRelayCard canManage={canManage} />
|
||||
<CallsRelayCard canManage={canManageRelay} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import { CommunicationSettingsCard } from "./CommunicationSettingsCard";
|
||||
import { PlatformSettingsCard } from "./PlatformSettingsCard";
|
||||
import { StorageSettingsCard } from "./StorageSettingsCard";
|
||||
import { GroupsSettingsCard } from "./GroupsSettingsCard";
|
||||
import { DEFAULT_SETTINGS_SECTION, SETTINGS_SECTIONS, type SettingsSectionKey } from "./sections";
|
||||
import { DEFAULT_SETTINGS_SECTION, visibleSettingsSections, type SettingsSectionKey } from "./sections";
|
||||
import { useIntegrations } from "./useIntegrations";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
@@ -54,7 +54,9 @@ export function SettingsPage({ user, onUserUpdated, reload, groups = [], section
|
||||
integrations: connections.length,
|
||||
};
|
||||
const active = section ?? (mobile ? null : DEFAULT_SETTINGS_SECTION);
|
||||
const current = SETTINGS_SECTIONS.find((item) => item.key === active) ?? null;
|
||||
// Разделы про саму установку видит только её администратор.
|
||||
const sections = visibleSettingsSections(user);
|
||||
const current = sections.find((item) => item.key === active) ?? null;
|
||||
|
||||
const links = (
|
||||
<>
|
||||
@@ -70,7 +72,7 @@ export function SettingsPage({ user, onUserUpdated, reload, groups = [], section
|
||||
<nav className={`settings-subnav ${mobile && current ? "is-hidden" : ""}`}>
|
||||
<div className="settings-subnav-head"><h2>{t("common.settings")}</h2></div>
|
||||
<div className="settings-subnav-list">
|
||||
{SETTINGS_SECTIONS.map((item) => (
|
||||
{sections.map((item) => (
|
||||
<button
|
||||
className={`settings-subnav-item ${item.key === active ? "is-active" : ""}`}
|
||||
key={item.key}
|
||||
@@ -153,9 +155,9 @@ function SectionBody({ section, user, onUserUpdated, reload, groups, integration
|
||||
}): ReactNode {
|
||||
if (section === "organization") return <OrganizationSection user={user} onUserUpdated={onUserUpdated} />;
|
||||
if (section === "groups") return <GroupsSettingsCard groups={groups} reload={reload} />;
|
||||
if (section === "communication") return <CommunicationSettingsCard canManage={canManageSettings(user)} />;
|
||||
if (section === "platform") return <PlatformSettingsCard canManage={canManageSettings(user)} />;
|
||||
if (section === "storage") return <StorageSettingsCard canManage={canManageSettings(user)} />;
|
||||
if (section === "communication") return <CommunicationSettingsCard canManage={canManageSettings(user)} canManageRelay={user.isInstanceAdmin} />;
|
||||
if (section === "platform") return <PlatformSettingsCard canManage={user.isInstanceAdmin} />;
|
||||
if (section === "storage") return <StorageSettingsCard canManage={user.isInstanceAdmin} />;
|
||||
if (section === "demo") return <DemoDataCard reload={reload} />;
|
||||
if (integrations.loading) return <LoadingState />;
|
||||
if (integrations.failed) return <EmptyState title={t("settings.could_not_load_integrations")} />;
|
||||
|
||||
@@ -37,7 +37,7 @@ type Draft = {
|
||||
s3AddressingStyle: string;
|
||||
};
|
||||
|
||||
const BASE = "/api/v1/company/administration/storage/";
|
||||
const BASE = "/api/v1/instance/storage/";
|
||||
|
||||
function draftOf(payload: StoragePayload): Draft {
|
||||
return {
|
||||
|
||||
@@ -3,6 +3,8 @@ import { t } from "../../i18n";
|
||||
|
||||
// Настройки инсталляции (раздел «Платформа» и карточка relay в «Голосовых и
|
||||
// звонках»). Один эндпоинт на всю установку: адрес, исходящая почта и TURN.
|
||||
// Путь без организации: менять его вправе только администратор установки,
|
||||
// читать — менеджер любой организации.
|
||||
// PATCH частичный — блоки email и turn применяются, только если пришли в теле,
|
||||
// поэтому каждая карточка сохраняет свою часть и не затирает соседние.
|
||||
|
||||
@@ -28,7 +30,7 @@ export type InstancePayload = {
|
||||
turn: { urls: string[]; ttlSeconds: number; secretReady: boolean };
|
||||
};
|
||||
|
||||
export const INSTANCE_BASE = "/api/v1/company/administration/instance/";
|
||||
export const INSTANCE_BASE = "/api/v1/instance/settings/";
|
||||
|
||||
export function loadInstance(): Promise<InstancePayload> {
|
||||
return api<{ instance: InstancePayload }>(INSTANCE_BASE).then((payload) => payload.instance);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Icon } from "../../shared/icons";
|
||||
import type { SessionUser } from "../../types";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
// Разделы «Настроек» (дизайн-базлайн v2, кадры N1–N7): субменю 240px и один
|
||||
@@ -21,6 +22,8 @@ export type SettingsSection = {
|
||||
icon: Parameters<typeof Icon>[0]["name"];
|
||||
heading: string;
|
||||
lead: string;
|
||||
// Раздел про саму установку: виден только администратору установки.
|
||||
instanceOnly?: boolean;
|
||||
};
|
||||
|
||||
export const SETTINGS_SECTIONS: SettingsSection[] = [
|
||||
@@ -67,6 +70,7 @@ export const SETTINGS_SECTIONS: SettingsSection[] = [
|
||||
icon: "database",
|
||||
heading: t("settings.file_storage"),
|
||||
lead: t("settings.attachments_voice_messages_photos_logos"),
|
||||
instanceOnly: true,
|
||||
},
|
||||
{
|
||||
key: "platform",
|
||||
@@ -74,6 +78,7 @@ export const SETTINGS_SECTIONS: SettingsSection[] = [
|
||||
icon: "globe",
|
||||
heading: t("settings.platform"),
|
||||
lead: t("settings.properties_installation_itself_address_opened"),
|
||||
instanceOnly: true,
|
||||
},
|
||||
{
|
||||
key: "demo",
|
||||
@@ -86,6 +91,10 @@ export const SETTINGS_SECTIONS: SettingsSection[] = [
|
||||
|
||||
export const DEFAULT_SETTINGS_SECTION: SettingsSectionKey = "organization";
|
||||
|
||||
export function visibleSettingsSections(user: SessionUser): SettingsSection[] {
|
||||
return SETTINGS_SECTIONS.filter((section) => !section.instanceOnly || user.isInstanceAdmin);
|
||||
}
|
||||
|
||||
export function settingsSectionKey(value: string | null | undefined): SettingsSectionKey | null {
|
||||
const found = SETTINGS_SECTIONS.find((section) => section.key === value);
|
||||
return found ? found.key : null;
|
||||
|
||||
@@ -124,6 +124,9 @@ export const en: Record<MessageKey, Message> = {
|
||||
"admin.off_operator": "Off for this operator",
|
||||
"admin.one_moment": "One moment.",
|
||||
"admin.open_card": "Open the card",
|
||||
"admin.resend_invitation": "Resend the invitation",
|
||||
"admin.revoke_invitation": "Revoke the invitation",
|
||||
"admin.invitation_valid_until": "Invited until {date}",
|
||||
"admin.operator_blocked": "Operator blocked",
|
||||
"admin.operator_blocked_cannot_sign_unblocking": "The operator is blocked and cannot sign in. Unblocking restores access according to their active assignments.",
|
||||
"admin.operator_created": "Operator created",
|
||||
@@ -1042,6 +1045,13 @@ export const en: Record<MessageKey, Message> = {
|
||||
"profile.getting_started": "Getting started",
|
||||
"profile.getting_started_progress": "{done} of {total}",
|
||||
"profile.go_start_page": "Go to the start page",
|
||||
"profile.switch_organization": "Switch organization",
|
||||
"auth.invitation_title": "Organization invitation",
|
||||
"auth.invitation_accepting": "Accepting the invitation…",
|
||||
"auth.invitation_not_accepted": "The invitation was not accepted",
|
||||
"auth.invitation_invalid": "The invitation is invalid or has expired",
|
||||
"auth.join_subtitle": "You are invited to \u201c{organization}\u201d as {email}. Enter your name and set a password to sign in.",
|
||||
"auth.join_accept": "Accept the invitation",
|
||||
"profile.groups_list": "· groups: {groups}",
|
||||
"profile.in_organization_since": "· in the organization since {date}",
|
||||
"profile.interface_language": "Interface language",
|
||||
|
||||
@@ -125,6 +125,9 @@ export const ru = {
|
||||
"admin.off_operator": "Отключена для этого сотрудника",
|
||||
"admin.one_moment": "Подождите немного.",
|
||||
"admin.open_card": "Открыть карточку",
|
||||
"admin.resend_invitation": "Отправить приглашение ещё раз",
|
||||
"admin.revoke_invitation": "Отозвать приглашение",
|
||||
"admin.invitation_valid_until": "Приглашение до {date}",
|
||||
"admin.operator_blocked": "Сотрудник заблокирован",
|
||||
"admin.operator_blocked_cannot_sign_unblocking": "Сотрудник заблокирован и не может войти. Разблокировка восстановит доступ по активным назначениям.",
|
||||
"admin.operator_created": "Сотрудник создан",
|
||||
@@ -1043,6 +1046,13 @@ export const ru = {
|
||||
"profile.getting_started": "Запуск",
|
||||
"profile.getting_started_progress": "{done} из {total}",
|
||||
"profile.go_start_page": "На главную",
|
||||
"profile.switch_organization": "Переключить организацию",
|
||||
"auth.invitation_title": "Приглашение в организацию",
|
||||
"auth.invitation_accepting": "Принимаем приглашение…",
|
||||
"auth.invitation_not_accepted": "Приглашение не принято",
|
||||
"auth.invitation_invalid": "Приглашение недействительно или истекло",
|
||||
"auth.join_subtitle": "Вас приглашают в «{organization}» как {email}. Укажите имя и задайте пароль для входа.",
|
||||
"auth.join_accept": "Принять приглашение",
|
||||
"profile.groups_list": "· группы: {groups}",
|
||||
"profile.in_organization_since": "· в организации с {date}",
|
||||
"profile.interface_language": "Язык интерфейса",
|
||||
|
||||
@@ -13,8 +13,8 @@ import { DemoInstallBanner } from "../features/settings/DemoInstallBanner";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { ShellRouteContent } from "./ShellRouteContent";
|
||||
|
||||
export function Shell({ route, setRoute, settingsSection, openSettingsRoute, selectedEmployeeId, selectedAgentId, selectedKnowledgeId, selectedConversationId, selectedClientId, selectedChannelId, selectedSupportPortalId, portalSettingsSection, openChannelRoute, openSupportPortalRoute, openPortalSettingsRoute, openEmployeeRoute, openAgentRoute, openKnowledgeRoute, openKnowledgeEditorRoute, openConversationRoute, openClientRoute, user, data, reload, onUserUpdated, onLogout }: { route: RouteKey; setRoute: (route: RouteKey) => void; settingsSection: SettingsSectionKey | null; openSettingsRoute: (section: SettingsSectionKey | null) => void; selectedEmployeeId: number | null; selectedAgentId: number | null; selectedKnowledgeId: number | null; selectedConversationId: number | null; selectedClientId: number | null; selectedChannelId: number | null; selectedSupportPortalId: number | null; portalSettingsSection: PortalSettingsSectionKey | null; openEmployeeRoute: (employeeId: number) => void; openAgentRoute: (agentId: number) => void; openKnowledgeRoute: (knowledgeId: number) => void;
|
||||
openKnowledgeEditorRoute: (knowledgeId: number | null) => void; openConversationRoute: (conversationId: number) => void; openClientRoute: (clientId: number) => void; openChannelRoute: (channelId: number) => void; openSupportPortalRoute: (portalId: number) => void; openPortalSettingsRoute: (portalId: number, section?: PortalSettingsSectionKey) => void; user: SessionUser; data: AppData; reload: () => void; onUserUpdated: (user: SessionUser) => void; onLogout: () => void }) {
|
||||
export function Shell({ route, setRoute, settingsSection, openSettingsRoute, selectedEmployeeId, selectedAgentId, selectedKnowledgeId, selectedConversationId, selectedClientId, selectedChannelId, selectedSupportPortalId, portalSettingsSection, openChannelRoute, openSupportPortalRoute, openPortalSettingsRoute, openEmployeeRoute, openAgentRoute, openKnowledgeRoute, openKnowledgeEditorRoute, openConversationRoute, openClientRoute, user, data, reload, onUserUpdated, onLogout, onSwitchOrganization }: { route: RouteKey; setRoute: (route: RouteKey) => void; settingsSection: SettingsSectionKey | null; openSettingsRoute: (section: SettingsSectionKey | null) => void; selectedEmployeeId: number | null; selectedAgentId: number | null; selectedKnowledgeId: number | null; selectedConversationId: number | null; selectedClientId: number | null; selectedChannelId: number | null; selectedSupportPortalId: number | null; portalSettingsSection: PortalSettingsSectionKey | null; openEmployeeRoute: (employeeId: number) => void; openAgentRoute: (agentId: number) => void; openKnowledgeRoute: (knowledgeId: number) => void;
|
||||
openKnowledgeEditorRoute: (knowledgeId: number | null) => void; openConversationRoute: (conversationId: number) => void; openClientRoute: (clientId: number) => void; openChannelRoute: (channelId: number) => void; openSupportPortalRoute: (portalId: number) => void; openPortalSettingsRoute: (portalId: number, section?: PortalSettingsSectionKey) => void; user: SessionUser; data: AppData; reload: () => void; onUserUpdated: (user: SessionUser) => void; onLogout: () => void; onSwitchOrganization: (organizationPublicId: string) => void }) {
|
||||
const [notifications, setNotifications] = useState<AppNotification[]>([]);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [notifOpen, setNotifOpen] = useState(false);
|
||||
@@ -112,7 +112,7 @@ export function Shell({ route, setRoute, settingsSection, openSettingsRoute, sel
|
||||
const isPortals = route === "supportPortals" || route === "supportPortalDetail" || route === "supportPortalSettings";
|
||||
return (
|
||||
<div className={`hub-shell ${isDialogsWorkspace ? "is-chat-route" : ""} ${isSettings ? "is-settings-route" : ""} ${isProfile ? "is-profile-route" : ""} ${isContacts ? "is-contacts-route" : ""}`}>
|
||||
<Sidebar route={route} user={user} setRoute={setRoute} openSettings={openSettingsRoute} onLogout={onLogout} waitingCount={waitingCount} chatScope={chatScope.scope} setChatScope={chatScope.setScope} chatCounters={chatScope.counters} unreadCount={unreadCount} onOpenNotifications={() => { setNotifOpen(true); void loadNotifications(); }} expanded={sidebarExpanded} setExpanded={setSidebarExpanded} />
|
||||
<Sidebar route={route} user={user} setRoute={setRoute} openSettings={openSettingsRoute} onLogout={onLogout} onSwitchOrganization={onSwitchOrganization} waitingCount={waitingCount} chatScope={chatScope.scope} setChatScope={chatScope.setScope} chatCounters={chatScope.counters} unreadCount={unreadCount} onOpenNotifications={() => { setNotifOpen(true); void loadNotifications(); }} expanded={sidebarExpanded} setExpanded={setSidebarExpanded} />
|
||||
<div className="hub-main">
|
||||
{manager && <DemoInstallBanner reload={reload} />}
|
||||
{/* Верхней панели нет ни у одной роли (дизайн-базлайн v2): заголовок и
|
||||
|
||||
@@ -47,6 +47,7 @@ export function Sidebar({
|
||||
setRoute,
|
||||
openSettings,
|
||||
onLogout,
|
||||
onSwitchOrganization,
|
||||
waitingCount = 0,
|
||||
chatScope,
|
||||
setChatScope,
|
||||
@@ -61,6 +62,7 @@ export function Sidebar({
|
||||
setRoute: (route: RouteKey) => void;
|
||||
openSettings: (section: SettingsSectionKey | null) => void;
|
||||
onLogout: () => void;
|
||||
onSwitchOrganization: (organizationPublicId: string) => void;
|
||||
waitingCount?: number;
|
||||
chatScope: DialogScope;
|
||||
setChatScope: (scope: DialogScope) => void;
|
||||
@@ -114,15 +116,33 @@ export function Sidebar({
|
||||
? <img src={user.organizationLogoUrl} alt="" />
|
||||
: <LogoIcon />}
|
||||
</button>
|
||||
{/* Переключатель организации (дизайн-базлайн v2, A1): пока у пользователя
|
||||
одна организация — в списке она одна, отмечена. */}
|
||||
{/* Переключатель организации (дизайн-базлайн v2, A1): все организации
|
||||
человека, текущая отмечена; выбор другой открывает её стартовый экран. */}
|
||||
<Dropdown
|
||||
trigger={["click"]}
|
||||
placement="bottomLeft"
|
||||
overlayClassName="app-dropdown is-wide"
|
||||
menu={{ items: [{ key: "current", label: <button type="button" className="is-checked"><Icon name="building" size={15} />{user.organizationName || "Chatballs"}</button> }] }}
|
||||
menu={{
|
||||
items: user.memberships.map((membership) => ({
|
||||
key: membership.organizationPublicId,
|
||||
label: (
|
||||
<button
|
||||
type="button"
|
||||
className={membership.organizationPublicId === user.organizationPublicId ? "is-checked" : ""}
|
||||
onClick={() => {
|
||||
if (membership.organizationPublicId !== user.organizationPublicId) {
|
||||
onSwitchOrganization(membership.organizationPublicId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon name="building" size={15} />
|
||||
{membership.organizationName || "Chatballs"}
|
||||
</button>
|
||||
),
|
||||
})),
|
||||
}}
|
||||
>
|
||||
<button className="hub-brand-switch" type="button">
|
||||
<button className="hub-brand-switch" type="button" title={t("profile.switch_organization")}>
|
||||
<span>{user.organizationName || "Chatballs"}</span>
|
||||
<Icon name="chevron" size={14} />
|
||||
</button>
|
||||
|
||||
@@ -42,6 +42,9 @@ export type AuthenticatedUser = {
|
||||
// Когда последний раз принимался код аутентификатора (кадр P1).
|
||||
totpLastUsedAt: string | null;
|
||||
deliveryMode: DeliveryMode;
|
||||
// Администратор установки: разделы «Платформа» и «Хранилище файлов» — его,
|
||||
// это свойства инсталляции, а не организации.
|
||||
isInstanceAdmin: boolean;
|
||||
memberships: OrganizationMembership[];
|
||||
uiTheme: "LIGHT" | "DARK" | "SYSTEM";
|
||||
uiAccent: string;
|
||||
@@ -100,6 +103,21 @@ export type Employee = {
|
||||
auditEvents?: EmployeeAuditEvent[];
|
||||
};
|
||||
|
||||
// Ожидающее приглашение существующей учётной записи (статус «Приглашён»):
|
||||
// роль, должность и группы уже известны и придут вместе с членством.
|
||||
export type EmployeeInvitation = {
|
||||
id: number;
|
||||
email: string;
|
||||
fullName: string;
|
||||
avatarUrl?: string | null;
|
||||
role: Role;
|
||||
positionTitle: string;
|
||||
phone: string;
|
||||
groups: EmployeeGroupRef[];
|
||||
invitedAt: string;
|
||||
expiresAt: string;
|
||||
};
|
||||
|
||||
export type EmployeeAuditEvent = {
|
||||
action: string;
|
||||
result: string;
|
||||
|
||||
+20
-3
@@ -35,6 +35,8 @@ services:
|
||||
restart: "no"
|
||||
volumes:
|
||||
- chatballs-secrets:/run/chatballs/secrets
|
||||
- chatballs-secrets-platform:/run/chatballs/secrets/platform
|
||||
- chatballs-secrets-schema:/run/chatballs/secrets/schema
|
||||
|
||||
postgres:
|
||||
image: ${CHATBALLS_POSTGRES_IMAGE:-chatballs-postgres:dev}
|
||||
@@ -42,18 +44,20 @@ services:
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-chatballs}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-chatballs_bootstrap}
|
||||
POSTGRES_PASSWORD_FILE: /run/chatballs/secrets/postgres_password
|
||||
POSTGRES_PASSWORD_FILE: /run/chatballs/secrets/schema/postgres_password
|
||||
POSTGRES_APP_USER: ${POSTGRES_APP_USER:-chatballs_app}
|
||||
POSTGRES_APP_PASSWORD_FILE: /run/chatballs/secrets/postgres_app_password
|
||||
POSTGRES_PLATFORM_USER: ${POSTGRES_PLATFORM_USER:-chatballs_platform}
|
||||
POSTGRES_PLATFORM_PASSWORD_FILE: /run/chatballs/secrets/postgres_platform_password
|
||||
POSTGRES_PLATFORM_PASSWORD_FILE: /run/chatballs/secrets/platform/postgres_platform_password
|
||||
POSTGRES_MIGRATION_USER: ${POSTGRES_MIGRATION_USER:-chatballs_migration}
|
||||
POSTGRES_MIGRATION_PASSWORD_FILE: /run/chatballs/secrets/postgres_migration_password
|
||||
POSTGRES_MIGRATION_PASSWORD_FILE: /run/chatballs/secrets/schema/postgres_migration_password
|
||||
depends_on:
|
||||
secrets:
|
||||
condition: service_completed_successfully
|
||||
volumes:
|
||||
- chatballs-secrets:/run/chatballs/secrets:ro
|
||||
- chatballs-secrets-platform:/run/chatballs/secrets/platform:ro
|
||||
- chatballs-secrets-schema:/run/chatballs/secrets/schema:ro
|
||||
- chatballs-postgres:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
||||
@@ -81,6 +85,7 @@ services:
|
||||
image: ${CHATBALLS_BACKEND_IMAGE:-chatballs-backend:dev}
|
||||
volumes:
|
||||
- chatballs-secrets:/run/chatballs/secrets:ro
|
||||
- chatballs-secrets-schema:/run/chatballs/secrets/schema:ro
|
||||
command: ["python", "manage.py", "migrate", "--noinput"]
|
||||
environment:
|
||||
CHATBALLS_DB_ROLE: migration
|
||||
@@ -103,6 +108,8 @@ services:
|
||||
environment:
|
||||
CHATBALLS_DB_ROLE: app
|
||||
volumes:
|
||||
# Только общий том: паролей platform и migration у публичного процесса
|
||||
# нет даже в файловой системе (они в своих томах, см. сервис secrets).
|
||||
- chatballs-secrets:/run/chatballs/secrets:ro
|
||||
# Локальные файлы — именованный том, а не каталог с хоста. Том наследует
|
||||
# владельца из образа (hub), поэтому загрузки работают и на Linux, где
|
||||
@@ -132,6 +139,7 @@ services:
|
||||
image: ${CHATBALLS_BACKEND_IMAGE:-chatballs-backend:dev}
|
||||
volumes:
|
||||
- chatballs-secrets:/run/chatballs/secrets:ro
|
||||
- chatballs-secrets-platform:/run/chatballs/secrets/platform:ro
|
||||
restart: unless-stopped
|
||||
command: >
|
||||
sh -c "gunicorn chatballs_backend.asgi_platform:application
|
||||
@@ -167,6 +175,7 @@ services:
|
||||
image: ${CHATBALLS_BACKEND_IMAGE:-chatballs-backend:dev}
|
||||
volumes:
|
||||
- chatballs-secrets:/run/chatballs/secrets:ro
|
||||
- chatballs-secrets-schema:/run/chatballs/secrets/schema:ro
|
||||
restart: unless-stopped
|
||||
command: >
|
||||
sh -c "gunicorn chatballs_backend.asgi_admin:application
|
||||
@@ -194,8 +203,12 @@ services:
|
||||
command: ["python", "manage.py", "run_worker"]
|
||||
environment:
|
||||
CHATBALLS_DB_ROLE: app
|
||||
# Захват outbox идёт по всем организациям сразу — воркеру нужен алиас
|
||||
# platform. У backend-app его нет: он работает только ролью app.
|
||||
CHATBALLS_DB_PLATFORM_ALIAS: "1"
|
||||
volumes:
|
||||
- chatballs-secrets:/run/chatballs/secrets:ro
|
||||
- chatballs-secrets-platform:/run/chatballs/secrets/platform:ro
|
||||
- chatballs-media:/app/apps/backend/media
|
||||
depends_on:
|
||||
backend-app:
|
||||
@@ -306,7 +319,11 @@ services:
|
||||
|
||||
volumes:
|
||||
# Секреты инстанса: генерируются при первом старте, живут только здесь.
|
||||
# Три тома, чтобы каждый процесс видел лишь свои пароли: общий, роли
|
||||
# platform и роли миграций (вместе с паролем владельца кластера).
|
||||
chatballs-secrets:
|
||||
chatballs-secrets-platform:
|
||||
chatballs-secrets-schema:
|
||||
# Состояние установки. Именованные тома вместо каталогов с хоста: установка
|
||||
# не зависит от того, из какого каталога её запустили, и не упирается в
|
||||
# владельца каталога на хосте.
|
||||
|
||||
@@ -112,6 +112,19 @@ cmd_doctor() {
|
||||
_doctor_report 0 "compose config invalid"
|
||||
fi
|
||||
|
||||
# Разделение томов с секретами: у публичного backend-app не должно быть
|
||||
# паролей ролей platform и migration даже в файловой системе. Проверяется
|
||||
# только на работающем стеке; до первого запуска проверять нечего.
|
||||
if run_compose ps --status running --services 2>/dev/null | grep -qx backend-app; then
|
||||
if run_compose exec -T backend-app sh -c \
|
||||
'for f in platform/postgres_platform_password schema/postgres_migration_password schema/postgres_password postgres_platform_password postgres_migration_password postgres_password; do test ! -s "/run/chatballs/secrets/$f" || exit 1; done' \
|
||||
>/dev/null 2>&1; then
|
||||
_doctor_report 1 "backend-app sees no platform/migration database passwords"
|
||||
else
|
||||
_doctor_report 0 "backend-app can read platform or migration database passwords"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$failures" != "0" ]]; then
|
||||
die "doctor: $failures check(s) failed" 1
|
||||
fi
|
||||
|
||||
@@ -2,6 +2,14 @@ server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
# Имена сервисов разрешаются на каждом запросе через DNS Docker, а не один
|
||||
# раз при старте nginx: после пересоздания контейнера (compose up, обновление
|
||||
# образа) адрес меняется, и закэшированный IP уводил бы трафик на другой
|
||||
# сервис. Для этого upstream задан переменной — с ней nginx резолвит заново
|
||||
# по истечении valid.
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
set $backend_app backend-app:8000;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
@@ -15,7 +23,7 @@ server {
|
||||
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||
|
||||
location = /chat-widget.js {
|
||||
proxy_pass http://backend-app:8000/chat-widget.js;
|
||||
proxy_pass http://$backend_app;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -32,7 +40,7 @@ server {
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend-app:8000/api/;
|
||||
proxy_pass http://$backend_app;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -42,7 +50,7 @@ server {
|
||||
|
||||
# WebSocket signaling звонков (SPEC-CHATBALLS-0013 §9).
|
||||
location /ws/ {
|
||||
proxy_pass http://backend-app:8000/ws/;
|
||||
proxy_pass http://$backend_app;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
+18
-7
@@ -1,3 +1,8 @@
|
||||
# Имена сервисов разрешаются на каждом запросе через DNS Docker, а не один
|
||||
# раз при старте nginx: после пересоздания контейнера (compose up, обновление
|
||||
# образа) адрес меняется, и закэшированный IP уводил бы трафик на другой
|
||||
# сервис. Для этого upstream задан переменной — с ней nginx резолвит заново
|
||||
# по истечении valid.
|
||||
# Vite HMR ходит по websocket на тот же origin, что и приложение, поэтому
|
||||
# location / должен уметь апгрейдить соединение. Обычные запросы при этом
|
||||
# закрывают connection как раньше.
|
||||
@@ -9,6 +14,10 @@ map $http_upgrade $connection_upgrade {
|
||||
server {
|
||||
listen 80;
|
||||
server_name app.localhost localhost *.localhost;
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
set $backend_app backend-app:8000;
|
||||
set $web_chat web-chat:5175;
|
||||
set $frontend frontend:5173;
|
||||
|
||||
location = /admin {
|
||||
return 404;
|
||||
@@ -19,14 +28,14 @@ server {
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend-app:8000;
|
||||
proxy_pass http://$backend_app;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /ws/ {
|
||||
proxy_pass http://backend-app:8000;
|
||||
proxy_pass http://$backend_app;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
@@ -35,14 +44,14 @@ server {
|
||||
}
|
||||
|
||||
location = /chat-widget.js {
|
||||
proxy_pass http://backend-app:8000;
|
||||
proxy_pass http://$backend_app;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location /chat/ {
|
||||
# Vite React Refresh injects an inline preamble in local development.
|
||||
add_header Content-Security-Policy "default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; media-src 'self' blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; object-src 'none'; base-uri 'self'; frame-ancestors *" always;
|
||||
proxy_pass http://web-chat:5175;
|
||||
proxy_pass http://$web_chat;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
@@ -54,7 +63,7 @@ server {
|
||||
add_header Content-Security-Policy "default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; media-src 'self' blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always;
|
||||
add_header Permissions-Policy "camera=(self), microphone=(self)" always;
|
||||
rewrite ^ /chat/call.html break;
|
||||
proxy_pass http://web-chat:5175;
|
||||
proxy_pass http://$web_chat;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
@@ -63,7 +72,7 @@ server {
|
||||
# local-only; production keeps the strict CSP from the canonical gateway.
|
||||
add_header Content-Security-Policy "default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always;
|
||||
add_header X-Frame-Options SAMEORIGIN always;
|
||||
proxy_pass http://frontend:5173;
|
||||
proxy_pass http://$frontend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
@@ -76,9 +85,11 @@ server {
|
||||
server {
|
||||
listen 80;
|
||||
server_name platform.localhost;
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
set $backend_platform backend-platform:8000;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend-platform:8000;
|
||||
proxy_pass http://$backend_platform;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
@@ -1,19 +1,33 @@
|
||||
#!/bin/sh
|
||||
# Секреты инстанса генерируются один раз при первом старте и живут в томе
|
||||
# chatballs-secrets. В .env, в репозитории и в руках человека их нет: установка
|
||||
# Секреты инстанса генерируются один раз при первом старте и живут в томах
|
||||
# chatballs-secrets*. В .env, в репозитории и в руках человека их нет: установка
|
||||
# — одна команда, всё остальное настраивается в UI.
|
||||
#
|
||||
# Томов три, и каждый процесс монтирует только свои:
|
||||
# $DIR — общие: ключ подписи, ключ шифрования, пароль роли app,
|
||||
# секрет TURN (том chatballs-secrets);
|
||||
# $DIR/platform — пароль роли platform (chatballs-secrets-platform):
|
||||
# платформенная поверхность и воркер;
|
||||
# $DIR/schema — пароль роли миграций и владельца кластера
|
||||
# (chatballs-secrets-schema): postgres, init, admin.
|
||||
# Публичный backend-app видит только общий том — пароли, дающие обход RLS,
|
||||
# ему недоступны даже при компрометации процесса.
|
||||
#
|
||||
# Скрипт идемпотентен: существующие файлы не трогает, поэтому перезапуск и
|
||||
# обновление стека не меняют пароли уже работающей базы.
|
||||
# обновление стека не меняют пароли уже работающей базы. Установки, где
|
||||
# пароли лежали плоско в общем томе, переносятся сюда же: файл копируется в
|
||||
# свой том, сверяется и только потом удаляется из общего.
|
||||
set -eu
|
||||
|
||||
DIR="${CHATBALLS_SECRETS_DIR:-/run/chatballs/secrets}"
|
||||
mkdir -p "$DIR"
|
||||
# Том виден только контейнерам стека, но читают его разные
|
||||
PLATFORM_DIR="$DIR/platform"
|
||||
SCHEMA_DIR="$DIR/schema"
|
||||
mkdir -p "$DIR" "$PLATFORM_DIR" "$SCHEMA_DIR"
|
||||
# Тома видны только контейнерам стека, но читают их разные
|
||||
# пользователи (postgres, backend), поэтому права как у docker
|
||||
# secrets: каталог 0755, файлы 0444. Права выставляются на каждом
|
||||
# запуске — старые установки чинятся сами.
|
||||
chmod 755 "$DIR"
|
||||
chmod 755 "$DIR" "$PLATFORM_DIR" "$SCHEMA_DIR"
|
||||
|
||||
# 32 байта энтропии в hex. openssl есть в alpine/postgres-образах; /dev/urandom —
|
||||
# запасной путь, если нет.
|
||||
@@ -25,9 +39,33 @@ random_secret() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Перенос плоского файла из общего тома в свой: только если в своём его ещё
|
||||
# нет. Копия сверяется побайтно, и лишь после этого оригинал удаляется —
|
||||
# обрыв на любом шаге оставляет как минимум один целый экземпляр.
|
||||
relocate_secret() {
|
||||
name="$1"
|
||||
target_dir="$2"
|
||||
source="$DIR/$name"
|
||||
target="$target_dir/$name"
|
||||
if [ -s "$target" ] || [ ! -s "$source" ]; then
|
||||
return 0
|
||||
fi
|
||||
cp "$source" "$target"
|
||||
if ! cmp -s "$source" "$target"; then
|
||||
rm -f "$target"
|
||||
echo "failed to relocate $name: copy mismatch" >&2
|
||||
exit 1
|
||||
fi
|
||||
chmod 444 "$target"
|
||||
chmod 644 "$source" 2>/dev/null || true
|
||||
rm -f "$source"
|
||||
echo "relocated $name to $(basename "$target_dir")/"
|
||||
}
|
||||
|
||||
ensure_secret() {
|
||||
name="$1"
|
||||
file="$DIR/$name"
|
||||
target_dir="${2:-$DIR}"
|
||||
file="$target_dir/$name"
|
||||
if [ -s "$file" ]; then
|
||||
return 0
|
||||
fi
|
||||
@@ -37,14 +75,26 @@ ensure_secret() {
|
||||
}
|
||||
|
||||
fix_permissions() {
|
||||
chmod 444 "$DIR"/* 2>/dev/null || true
|
||||
# Только файлы: каталоги platform/ и schema/ — точки монтирования своих
|
||||
# томов, и без бита исполнения на них postgres и backend не войдут внутрь.
|
||||
chmod 755 "$DIR" "$PLATFORM_DIR" "$SCHEMA_DIR"
|
||||
for d in "$DIR" "$PLATFORM_DIR" "$SCHEMA_DIR"; do
|
||||
for f in "$d"/*; do
|
||||
[ -f "$f" ] && chmod 444 "$f"
|
||||
done
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
relocate_secret postgres_platform_password "$PLATFORM_DIR"
|
||||
relocate_secret postgres_migration_password "$SCHEMA_DIR"
|
||||
relocate_secret postgres_password "$SCHEMA_DIR"
|
||||
|
||||
ensure_secret secret_key
|
||||
ensure_secret postgres_password
|
||||
ensure_secret postgres_password "$SCHEMA_DIR"
|
||||
ensure_secret postgres_app_password
|
||||
ensure_secret postgres_platform_password
|
||||
ensure_secret postgres_migration_password
|
||||
ensure_secret postgres_platform_password "$PLATFORM_DIR"
|
||||
ensure_secret postgres_migration_password "$SCHEMA_DIR"
|
||||
# Общий секрет TURN: его знают приложение и coturn. Человек его не вводит —
|
||||
# иначе пришлось бы вписывать одно и то же значение в двух местах.
|
||||
ensure_secret turn_secret
|
||||
|
||||
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user