✉️ feat(identity): приглашения в организацию — существующие учётные записи и владелец из провижининга

Учётная запись глобальная, а во вторую организацию было не попасть: форма
создания сотрудника отвечала «e-mail занят». Теперь занятый адрес из другой
организации получает приглашение с той же ролью, должностью, телефоном и
группами; членство появляется, когда человек принял его под своим входом.
Письмо отправляет воркер и выпускает токен в момент отправки — открытый
токен нигде не хранится. Повторное приглашение заменяет прежнее.

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

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Fable 5.1 committed 2026-09-12 14:37:25 +03:00
1 parent 1390ccc426
commit 3e790af6e5
17 files changed
+929 -45

No files matched your search

@@ -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": "Проверка кода уже неактуальна — войдите заново",
@@ -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,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"),
]
+35
View File
@@ -12,6 +12,7 @@ from chatballs.identity.instance_settings import (
email_from_address,
public_base_url,
)
from chatballs.identity.invitation_models import OrganizationInvitation
from chatballs.identity.models import HumanUser
@@ -60,6 +61,40 @@ def send_initial_access_email(user: HumanUser) -> None:
)
def send_membership_invitation_email(
invitation: OrganizationInvitation, token: str, user: HumanUser | None = None
) -> None:
"""Приглашение в организацию по ссылке /join.
Существующая учётная запись входит под своим паролем, новая создаёт его
по той же ссылке. Язык — получателя, если он известен, дальше
приглашающей организации.
"""
join_url = f"{public_base_url()}/join?token={token}"
language = resolve_language(
user_language=user.ui_language if user else "",
organization_language=invitation.organization.language,
instance_language=default_language(),
)
with translation.override(language):
send_mail(
subject=t(
"emails.membership_invitation_subject",
organization=invitation.organization.name,
),
message=t(
"emails.membership_invitation_body",
name=(user.full_name if user else "") or invitation.email,
organization=invitation.organization.name,
url=join_url,
),
from_email=email_from_address(),
recipient_list=[invitation.email],
connection=email_connection(),
)
def send_password_reset_email(user: HumanUser) -> None:
reset_url = _password_setup_url(user)
with translation.override(_recipient_language(user)):
@@ -0,0 +1,159 @@
"""Ожидающие приглашения в списке сотрудников (кадры E1/E2, статус «Приглашён»).
Приглашение существующей учётной записи ещё не членство, но администратор
должен его видеть там же, где сотрудников: строкой с той же ролью, должностью
и группами, что придут после принятия. Отсюда же приглашение отправляют ещё
раз или отзывают.
"""
from __future__ import annotations
from django.utils import timezone
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from chatballs.events.services import DomainEvent, enqueue_event
from chatballs.i18n import t
from chatballs.identity.audit import record_audit_event
from chatballs.identity.avatars import user_avatar_url
from chatballs.identity.employee_selectors import ROLE_ANY
from chatballs.identity.governance import can_create_role
from chatballs.identity.group_models import EmployeeGroup
from chatballs.identity.invitation_models import OrganizationInvitation
from chatballs.identity.invitation_service import (
MEMBERSHIP_INVITATION_REQUESTED,
MEMBERSHIP_INVITATION_TTL,
)
from chatballs.identity.models import HumanUser, Organization, OrganizationMembership
from chatballs.identity.policy import has_capability_any_scope
def _pending(organization_id: int):
return OrganizationInvitation.objects.filter(
organization_id=organization_id,
accepted_at__isnull=True,
revoked_at__isnull=True,
expires_at__gt=timezone.now(),
)
def pending_invitations_payload(organization: Organization, params) -> list[dict[str, object]]:
"""Строки приглашений с теми же фильтрами, что у списка сотрудников."""
invitations = list(_pending(organization.id).order_by("email"))
if not invitations:
return []
users = {
user.email.lower(): user
for user in HumanUser.objects.filter(
email__in=[invitation.email for invitation in invitations]
)
}
group_ids = {gid for invitation in invitations for gid in invitation.group_ids}
groups = {
group.id: group
for group in EmployeeGroup.objects.filter(organization=organization, id__in=group_ids)
}
role = params.get("role")
group_filter = params.get("group")
query = params.get("q", "").strip().lower()
rows: list[dict[str, object]] = []
for invitation in invitations:
user = users.get(invitation.email.lower())
if user is None:
continue
if role and role != ROLE_ANY and invitation.role != role:
continue
if group_filter and group_filter != ROLE_ANY and str(group_filter).isdigit():
if int(group_filter) not in invitation.group_ids:
continue
haystack = f"{user.full_name} {user.email} {invitation.position_title}".lower()
if query and query not in haystack:
continue
rows.append(
{
"id": invitation.id,
"email": user.email,
"fullName": user.full_name,
"avatarUrl": user_avatar_url(user, organization.public_id),
"role": invitation.role,
"positionTitle": invitation.position_title,
"phone": invitation.phone,
"groups": [
{"id": gid, "name": groups[gid].name}
for gid in invitation.group_ids
if gid in groups
],
"invitedAt": invitation.created_at.isoformat(),
"expiresAt": invitation.expires_at.isoformat(),
}
)
return rows
def _load_for_action(request: Request, invitation_id: int) -> OrganizationInvitation | Response:
actor: OrganizationMembership = request.tenant_context.membership
if not has_capability_any_scope(actor, "employees.manage"):
return Response({"detail": t("admin.not_allowed")}, status=403)
invitation = _pending(actor.organization_id).filter(pk=invitation_id).first()
if invitation is None:
return Response({"detail": t("identity.invitation_invalid")}, status=404)
# Приглашение с ролью, которую актор выдать не вправе, ему и не отозвать.
if not can_create_role(actor, invitation.role):
return Response({"detail": t("admin.not_allowed")}, status=403)
return invitation
class InvitationResendView(APIView):
"""Отправить письмо ещё раз: срок продлевается, токен выпускает воркер."""
permission_classes = [IsAuthenticated]
def post(self, request: Request, invitation_id: int) -> Response:
loaded = _load_for_action(request, invitation_id)
if isinstance(loaded, Response):
return loaded
loaded.expires_at = timezone.now() + MEMBERSHIP_INVITATION_TTL
loaded.save(update_fields=["expires_at"])
record_audit_event(
action="identity.employee_invited",
actor=request.user,
organization=loaded.organization,
object_type="OrganizationInvitation",
object_id=str(loaded.id),
payload={"role": loaded.role, "resend": True},
request=request,
)
enqueue_event(
DomainEvent(
aggregate_type="OrganizationInvitation",
aggregate_id=str(loaded.id),
event_type=MEMBERSHIP_INVITATION_REQUESTED,
payload={"invitationId": loaded.id},
tenant_context=request.tenant_context,
)
)
return Response({"ok": True})
class InvitationRevokeView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request: Request, invitation_id: int) -> Response:
loaded = _load_for_action(request, invitation_id)
if isinstance(loaded, Response):
return loaded
loaded.revoked_at = timezone.now()
loaded.save(update_fields=["revoked_at"])
record_audit_event(
action="identity.invitation_revoked",
actor=request.user,
organization=loaded.organization,
object_type="OrganizationInvitation",
object_id=str(loaded.id),
payload={"role": loaded.role},
request=request,
)
return Response({"ok": True})
@@ -1,6 +1,7 @@
from django.urls import path
from chatballs.identity import (
employee_invitations,
employee_security_views,
employee_views,
ownership_views,
@@ -11,6 +12,9 @@ urlpatterns = [
# Создание сотрудника (ADMIN/EMPLOYEE по policy). Путь operators/ сохранён для
# обратной совместимости; поле role в теле выбирает системную роль.
path("operators/", employee_views.EmployeeCreateView.as_view(), name="employee-create"),
# Ожидающие приглашения существующих учётных записей (статус «Приглашён»).
path("invitations/<int:invitation_id>/resend/", employee_invitations.InvitationResendView.as_view(), name="employee-invitation-resend"),
path("invitations/<int:invitation_id>/revoke/", employee_invitations.InvitationRevokeView.as_view(), name="employee-invitation-revoke"),
path("<int:user_id>/", employee_views.EmployeeDetailView.as_view(), name="employee-detail"),
path("<int:user_id>/avatar/", employee_views.EmployeeAvatarView.as_view(), name="employee-avatar"),
path("<int:user_id>/update/", employee_views.EmployeeUpdateView.as_view(), name="employee-update"),
@@ -9,6 +9,7 @@ from chatballs.api.pagination import page_payload, paginate
from chatballs.events.services import DomainEvent, enqueue_event
from chatballs.i18n import t
from chatballs.identity.audit import record_audit_event
from chatballs.identity.employee_invitations import pending_invitations_payload
from chatballs.identity.employee_password import clean_password_mode, issue_initial_password
from chatballs.identity.employee_selectors import employees_for
from chatballs.identity.employee_support import employee_payload, get_owned_profile
@@ -21,6 +22,7 @@ from chatballs.identity.employee_validation import (
from chatballs.identity.event_handlers import INITIAL_ACCESS_REQUESTED
from chatballs.identity.governance import EmployeeAction, can_create_role, can_manage_employee
from chatballs.identity.group_models import EmployeeGroupMember
from chatballs.identity.invitation_service import invite_existing_user
from chatballs.identity.models import EmployeeRole, HumanUser, OrganizationMembership
from chatballs.identity.policy import has_capability_any_scope
@@ -72,7 +74,13 @@ class EmployeeListView(APIView):
employees_for(actor.organization_id, request.query_params),
request.query_params,
)
return Response(page_payload(page, lambda employee: employee_payload(employee, actor)))
payload = page_payload(page, lambda employee: employee_payload(employee, actor))
# Ожидающие приглашения — строками со статусом «Приглашён»: их мало,
# они не листаются и показываются на первой странице.
payload["invitations"] = pending_invitations_payload(
actor.organization, request.query_params
)
return Response(payload)
class EmployeeCreateView(APIView):
@@ -107,13 +115,33 @@ class EmployeeCreateView(APIView):
return Response({"detail": t("admin.temporary_passwords_unsupported")}, status=400)
if password_mode is None:
return Response({"detail": t("admin.unknown_password_mode")}, status=400)
if HumanUser.objects.filter(email=email).exists():
return Response({"detail": t("admin.email_taken")}, status=400)
groups, groups_error = resolve_groups(actor.organization, body.get("groupIds"))
if groups_error:
return Response({"detail": groups_error}, status=400)
existing = HumanUser.objects.filter(email=email).first()
if existing is not None:
# Учётная запись глобальная, а вход в организацию — по согласию
# человека: вместо «e-mail занят» уходит приглашение с той же ролью
# и должностью, членство появится после его принятия. В своей
# организации адрес и правда занят.
if not existing.is_active or OrganizationMembership.objects.filter(
user=existing, organization=actor.organization
).exists():
return Response({"detail": t("admin.email_taken")}, status=400)
invite_existing_user(
organization=actor.organization,
user=existing,
role=requested_role,
position_title=position_title,
phone=phone,
groups=groups or [],
created_by=actor,
context=request.tenant_context,
request=request,
)
return Response({"employee": None, "password": None, "invited": True}, status=201)
user = HumanUser.objects.create_user(
email=email,
password=None,
@@ -1,5 +1,15 @@
from chatballs.events.handlers import register
from chatballs.identity.emails import send_initial_access_email, send_password_reset_email
from chatballs.identity.emails import (
send_initial_access_email,
send_membership_invitation_email,
send_password_reset_email,
)
from chatballs.identity.invitation_models import OrganizationInvitation
from chatballs.identity.invitation_service import (
MEMBERSHIP_INVITATION_REQUESTED,
OWNER_INVITATION_REQUESTED,
refresh_invitation_token,
)
from chatballs.identity.models import HumanUser
from chatballs.tenancy.context import TenantContext
@@ -39,6 +49,37 @@ def handle_password_reset_requested(payload: dict, context: TenantContext | None
send_password_reset_email(user)
def _send_invitation(payload: dict, context: TenantContext | None, *, require_account: bool) -> None:
if context is None:
raise ValueError("Invitation is a tenant event")
invitation = (
OrganizationInvitation.objects.select_related("organization")
.filter(pk=payload.get("invitationId"), organization_id=context.organization_id)
.first()
)
if invitation is None or not invitation.is_pending:
return
user = HumanUser.objects.filter(email__iexact=invitation.email, is_active=True).first()
if user is None and require_account:
return
# Токен выпускается здесь, в момент отправки: открытый токен нигде не
# хранится, а письмо уходит позже, чем приглашение выписано.
token = refresh_invitation_token(invitation)
send_membership_invitation_email(invitation, token, user)
@register(MEMBERSHIP_INVITATION_REQUESTED)
def handle_membership_invitation_requested(payload: dict, context: TenantContext | None) -> None:
# Сотрудника приглашают только с существующей учётной записью.
_send_invitation(payload, context, require_account=True)
@register(OWNER_INVITATION_REQUESTED)
def handle_owner_invitation_requested(payload: dict, context: TenantContext | None) -> None:
# Владельца из провижининга учётная запись может ждать: он создаст её по ссылке.
_send_invitation(payload, context, require_account=False)
# --- Демо-данные (мастер первого запуска и «Настройки») -----------------------
DEMO_INSTALL_REQUESTED = "demo.install_requested"
@@ -19,6 +19,12 @@ class OrganizationInvitation(models.Model):
)
email = models.EmailField()
role = models.CharField(max_length=32, choices=EmployeeRole.choices)
# Поля будущего членства: приглашение существующего пользователя несёт
# то же, что форма создания сотрудника, а членство собирается из них при
# принятии. Группы — по id: к моменту принятия часть могла исчезнуть.
position_title = models.CharField(max_length=120, blank=True, default="")
phone = models.CharField(max_length=32, blank=True, default="")
group_ids = models.JSONField(default=list, blank=True)
token_hash = models.CharField(max_length=128, unique=True)
expires_at = models.DateTimeField()
created_by = models.ForeignKey(
@@ -3,13 +3,18 @@ from __future__ import annotations
import hashlib
import secrets
from dataclasses import dataclass
from datetime import datetime
from datetime import datetime, timedelta
from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError
from django.db import transaction
from django.utils import timezone
from chatballs.events.services import DomainEvent, enqueue_event
from chatballs.i18n import t
from chatballs.i18n.audience import customer_language
from chatballs.identity.audit import record_audit_event
from chatballs.identity.group_models import EmployeeGroup, EmployeeGroupMember
from chatballs.identity.models import (
EmployeeRole,
HumanUser,
@@ -18,8 +23,17 @@ from chatballs.identity.models import (
OrganizationMembership,
OrganizationStatus,
)
from chatballs.tenancy.context import TenantContext
from chatballs.tenancy.database import tenant_atomic
# Приглашение существующего пользователя в организацию: письмо отправляет
# воркер по этому событию (identity.event_handlers).
MEMBERSHIP_INVITATION_REQUESTED = "identity.membership_invitation_requested"
# Приглашение владельца из платформенного провижининга (SPEC-HUB-0021 §8.2):
# учётной записи может ещё не быть, тогда человек создаёт её по ссылке.
OWNER_INVITATION_REQUESTED = "organization.owner_invitation_requested"
MEMBERSHIP_INVITATION_TTL = timedelta(days=7)
def _token_hash(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
@@ -54,15 +68,19 @@ def issue_invitation(
role: str,
expires_at: datetime,
created_by: OrganizationMembership | None,
position_title: str = "",
phone: str = "",
group_ids: list[int] | None = None,
) -> IssuedInvitation:
normalized_email = email.strip().lower()
now = timezone.now()
# Повторное приглашение на тот же адрес заменяет прежнее: старое письмо
# перестаёт работать, а не живёт параллельно с новым.
OrganizationInvitation.objects.filter(
organization=organization,
email__iexact=normalized_email,
accepted_at__isnull=True,
revoked_at__isnull=True,
expires_at__lte=now,
).update(revoked_at=now)
token = secrets.token_urlsafe(32)
invitation = OrganizationInvitation.objects.create(
@@ -72,10 +90,119 @@ def issue_invitation(
token_hash=_token_hash(token),
expires_at=expires_at,
created_by=created_by,
position_title=position_title,
phone=phone,
group_ids=list(group_ids or []),
)
return IssuedInvitation(invitation=invitation, token=token)
def refresh_invitation_token(invitation: OrganizationInvitation) -> str:
"""Новый токен взамен прежнего.
Открытый токен нигде не хранится, а письмо уходит из воркера позже, чем
приглашение выписано, — поэтому воркер выпускает токен сам, в момент
отправки, и старый перестаёт действовать.
"""
token = secrets.token_urlsafe(32)
invitation.token_hash = _token_hash(token)
invitation.save(update_fields=["token_hash"])
return token
def invite_existing_user(
*,
organization: Organization,
user: HumanUser,
role: str,
position_title: str,
phone: str,
groups: list[EmployeeGroup],
created_by: OrganizationMembership | None,
context: TenantContext,
request=None,
) -> OrganizationInvitation:
"""Пригласить уже существующую учётную запись в организацию.
Учётная запись глобальная, а членство появляется только с согласия
человека: он получает письмо и принимает приглашение под своим входом.
"""
issued = issue_invitation(
organization=organization,
email=user.email,
role=role,
expires_at=timezone.now() + MEMBERSHIP_INVITATION_TTL,
created_by=created_by,
position_title=position_title,
phone=phone,
group_ids=[group.id for group in groups],
)
record_audit_event(
action="identity.employee_invited",
actor=context.actor_user,
organization=organization,
object_type="OrganizationInvitation",
object_id=str(issued.invitation.id),
payload={"role": role},
request=request,
)
enqueue_event(
DomainEvent(
aggregate_type="OrganizationInvitation",
aggregate_id=str(issued.invitation.id),
event_type=MEMBERSHIP_INVITATION_REQUESTED,
payload={"invitationId": issued.invitation.id},
tenant_context=context,
)
)
return issued.invitation
def invitation_preview(token: str) -> dict[str, object] | None:
"""Что видит человек по ссылке до входа: куда зовут и есть ли учётная запись."""
invitation = pending_invitation_for_token(token)
if invitation is None:
return None
return {
"email": invitation.email,
"organizationName": invitation.organization.name,
"accountExists": HumanUser.objects.filter(email__iexact=invitation.email).exists(),
}
@transaction.atomic
def register_and_accept(*, token: str, full_name: str, password: str) -> AcceptedInvitation:
"""Создать учётную запись по приглашению и сразу принять его.
Только для адреса без учётной записи: у существующей есть пароль, и
приглашение принимается после входа. Пароль проверяется теми же
правилами, что в мастере первого запуска.
"""
invitation = pending_invitation_for_token(token)
if invitation is None:
raise InvitationError(t("identity.invitation_invalid"), code="invitation_invalid")
if HumanUser.objects.filter(email__iexact=invitation.email).exists():
raise InvitationError(t("identity.invitation_account_exists"), code="account_exists")
name = " ".join(full_name.split())
if not name:
raise ValidationError({"fullName": t("setup.your_name_required")})
probe = HumanUser(email=invitation.email, full_name=name)
validate_password(password, user=probe)
user = HumanUser.objects.create_user(
email=invitation.email,
password=password,
full_name=name,
is_staff=False,
is_superuser=False,
must_change_password=False,
)
return accept_invitation(token=token, user=user)
def pending_invitation_for_token(token: str) -> OrganizationInvitation | None:
if not token:
return None
@@ -89,10 +216,11 @@ def pending_invitation_for_token(token: str) -> OrganizationInvitation | None:
@transaction.atomic
def accept_invitation(*, token: str, user: HumanUser) -> AcceptedInvitation:
"""Accept an OWNER invitation and activate the organization (SPEC-HUB-0021 §8.2).
"""Принять приглашение: владельца (SPEC-HUB-0021 §8.2) или сотрудника.
Idempotent: re-accepting the same token does not create a second membership
or usage period. Requires an authenticated HumanUser with a matching email.
Членство собирается из полей приглашения; для владельца организация ещё
и активируется. Идемпотентно: повторное принятие того же токена не создаёт
второго членства. Нужна учётная запись с тем же e-mail.
"""
invitation = pending_invitation_for_token(token)
if invitation is None:
@@ -110,13 +238,17 @@ def accept_invitation(*, token: str, user: HumanUser) -> AcceptedInvitation:
organization = invitation.organization
with tenant_atomic(organization.id):
membership = _ensure_owner_membership(organization, user)
membership = _ensure_membership(organization, user, invitation)
if invitation.role == EmployeeRole.OWNER:
_activate_organization(organization, user, membership)
invitation.accepted_at = timezone.now()
invitation.save(update_fields=["accepted_at"])
record_audit_event(
action="organization.owner_activated",
action=(
"organization.owner_activated"
if invitation.role == EmployeeRole.OWNER
else "identity.invitation_accepted"
),
actor=user,
organization=organization,
object_type="OrganizationInvitation",
@@ -128,25 +260,42 @@ def accept_invitation(*, token: str, user: HumanUser) -> AcceptedInvitation:
)
def _ensure_owner_membership(
organization: Organization, user: HumanUser
def _ensure_membership(
organization: Organization, user: HumanUser, invitation: OrganizationInvitation
) -> OrganizationMembership:
membership, _ = OrganizationMembership.objects.get_or_create(
position_title = invitation.position_title
if not position_title and invitation.role == EmployeeRole.OWNER:
position_title = t("setup.owner_position", language=customer_language(organization))
membership, created = OrganizationMembership.objects.get_or_create(
user=user,
organization=organization,
defaults={
"role": EmployeeRole.OWNER,
"position_title": "Владелец",
"role": invitation.role,
"position_title": position_title,
"phone": invitation.phone,
"totp_required": False,
},
)
if membership.role != EmployeeRole.OWNER:
# An existing non-OWNER membership for this user should not be silently
# promoted by an invitation; surface as a conflict instead.
if membership.role != invitation.role:
# Уже существующее членство с другой ролью приглашение молча не меняет:
# это конфликт, а не повышение.
raise InvitationError(
t("identity.role_conflict"),
code="role_conflict",
)
if created and invitation.group_ids:
# Группы, которые к моменту принятия ещё существуют.
groups = EmployeeGroup.objects.filter(
organization=organization, id__in=list(invitation.group_ids)
)
EmployeeGroupMember.objects.bulk_create(
[
EmployeeGroupMember(
organization_id=organization.id, group=group, employee=membership
)
for group in groups
]
)
return membership
@@ -0,0 +1,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),
),
]
+2 -18
View File
@@ -9,7 +9,7 @@ from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView
from chatballs.i18n import t
from chatballs.identity.auth.common import _user_payload
from chatballs.identity.auth.common import _user_payload, validation_response
from chatballs.identity.setup import (
SetupAlreadyCompleted,
SetupInput,
@@ -23,22 +23,6 @@ def setup_closed() -> dict[str, str]:
return {"detail": t("identity.setup_already_done")}
def _validation_response(error: ValidationError) -> Response:
if hasattr(error, "message_dict"):
errors = {
key: messages[0] if isinstance(messages, list) else str(messages)
for key, messages in error.message_dict.items()
}
# validate_password кладёт сообщения без ключа поля.
if "__all__" in errors:
errors["password"] = " ".join(error.message_dict["__all__"])
del errors["__all__"]
detail = next(iter(errors.values()), t("setup.check_fields"))
return Response({"detail": detail, "errors": errors}, status=400)
message = " ".join(error.messages)
return Response({"detail": message, "errors": {"password": message}}, status=400)
@method_decorator(ensure_csrf_cookie, name="dispatch")
class SetupStatusView(APIView):
authentication_classes: list = []
@@ -75,7 +59,7 @@ class SetupView(APIView):
except SetupAlreadyCompleted:
return Response(setup_closed(), status=409)
except ValidationError as error:
return _validation_response(error)
return validation_response(error)
owner = result.owner
owner.backend = "django.contrib.auth.backends.ModelBackend"
login(request, owner)
@@ -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)
@@ -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})