diff --git a/apps/backend/hub_platform/calls/permissions.py b/apps/backend/hub_platform/calls/permissions.py index 0f89bd3..6ebd43b 100644 --- a/apps/backend/hub_platform/calls/permissions.py +++ b/apps/backend/hub_platform/calls/permissions.py @@ -1,7 +1,7 @@ from hub_platform.calls.errors import CallAccessDenied from hub_platform.calls.models import CallSession from hub_platform.conversations.models import Conversation -from hub_platform.identity.models import HumanUser +from hub_platform.identity.models import OrganizationMembership from hub_platform.identity.policy import require_capability @@ -15,16 +15,19 @@ def ensure_call_access(*, user, call_session: CallSession) -> None: def staff_call_access_valid(*, user_id: int, call_session_id) -> bool: - user = HumanUser.objects.filter(id=user_id).select_related( - "employee_profile", "employee_profile__organization" - ).first() call = CallSession.objects.select_related( "conversation", "conversation__channel" ).filter(id=call_session_id).first() - if user is None or call is None: + if call is None: + return False + membership = OrganizationMembership.objects.select_related("user", "organization").filter( + user_id=user_id, + organization_id=call.conversation.channel.organization_id, + ).first() + if membership is None: return False try: - ensure_call_access(user=user, call_session=call) + ensure_call_access(user=membership, call_session=call) except CallAccessDenied: return False return True diff --git a/apps/backend/hub_platform/identity/access_models.py b/apps/backend/hub_platform/identity/access_models.py index 4cfb0b1..f691c37 100644 --- a/apps/backend/hub_platform/identity/access_models.py +++ b/apps/backend/hub_platform/identity/access_models.py @@ -6,7 +6,7 @@ from django.db.models import Q from django.db.models.functions import Lower from hub_platform.identity.capabilities import CAPABILITY_REGISTRY, ScopeType, capability_spec -from hub_platform.identity.models import Department, EmployeeProfile, Organization +from hub_platform.identity.models import Department, Organization, OrganizationMembership class AccessProfile(models.Model): @@ -79,7 +79,9 @@ class AccessProfileCapability(models.Model): class EmployeeAccessAssignment(models.Model): employee = models.ForeignKey( - EmployeeProfile, on_delete=models.PROTECT, related_name="access_assignments" + OrganizationMembership, + on_delete=models.PROTECT, + related_name="access_assignments", ) access_profile = models.ForeignKey( AccessProfile, on_delete=models.PROTECT, related_name="assignments" @@ -96,7 +98,7 @@ class EmployeeAccessAssignment(models.Model): blank=True, ) assigned_by = models.ForeignKey( - EmployeeProfile, + OrganizationMembership, on_delete=models.PROTECT, related_name="access_assignments_created", ) diff --git a/apps/backend/hub_platform/identity/access_views.py b/apps/backend/hub_platform/identity/access_views.py index 854ba14..f233d8d 100644 --- a/apps/backend/hub_platform/identity/access_views.py +++ b/apps/backend/hub_platform/identity/access_views.py @@ -25,7 +25,6 @@ from hub_platform.identity.models import ( EmployeeProfile, ) from hub_platform.identity.policy import can_administer_access -from hub_platform.identity.sessions import revoke_user_sessions def _manager_required(request: Request) -> Response | None: @@ -136,11 +135,6 @@ class AccessProfileDetailView(APIView): {"detail": "Profile capabilities conflict with active assignment scopes"}, status=409, ) - affected_user_ids = list( - profile.assignments.filter(revoked_at__isnull=True).values_list( - "employee__user_id", flat=True - ) - ) profile.name = str(request.data.get("name", profile.name)).strip() if not profile.name: return Response({"detail": "Access profile name is required"}, status=400) @@ -166,8 +160,6 @@ class AccessProfileDetailView(APIView): payload={"capabilities": codes}, request=request, ) - for user_id in affected_user_ids: - revoke_user_sessions(user_id) return Response({"profile": profile_payload(profile)}) def delete(self, request: Request, profile_id: int) -> Response: @@ -220,7 +212,6 @@ class EmployeeAccessAssignmentView(APIView): payload={"employeeId": target.user_id, "scopeType": assignment.scope_type}, request=request, ) - revoke_user_sessions(target.user_id) return Response({"assignment": assignment_payload(assignment)}, status=201) @@ -251,5 +242,4 @@ class EmployeeAccessAssignmentRevokeView(APIView): object_id=str(assignment.id), request=request, ) - revoke_user_sessions(assignment.employee.user_id) return Response({"assignment": assignment_payload(assignment)}) diff --git a/apps/backend/hub_platform/identity/admin.py b/apps/backend/hub_platform/identity/admin.py index 76d2950..8757d85 100644 --- a/apps/backend/hub_platform/identity/admin.py +++ b/apps/backend/hub_platform/identity/admin.py @@ -4,9 +4,10 @@ from django.contrib.auth.admin import UserAdmin from hub_platform.identity.models import ( AuditEvent, Department, - EmployeeProfile, HumanUser, Organization, + OrganizationInvitation, + OrganizationMembership, ) @@ -45,8 +46,8 @@ class DepartmentAdmin(admin.ModelAdmin): search_fields = ["code", "name"] -@admin.register(EmployeeProfile) -class EmployeeProfileAdmin(admin.ModelAdmin): +@admin.register(OrganizationMembership) +class OrganizationMembershipAdmin(admin.ModelAdmin): list_display = [ "user", "organization", @@ -57,9 +58,33 @@ class EmployeeProfileAdmin(admin.ModelAdmin): "totp_required", "blocked_at", ] - list_filter = ["organization", "role", "primary_department", "must_change_password", "totp_required"] + list_filter = [ + "organization", + "role", + "primary_department", + "user__must_change_password", + "totp_required", + ] search_fields = ["user__email", "user__full_name"] + @admin.display(boolean=True, ordering="user__must_change_password") + def must_change_password(self, membership: OrganizationMembership) -> bool: + return membership.user.must_change_password + + +@admin.register(OrganizationInvitation) +class OrganizationInvitationAdmin(admin.ModelAdmin): + list_display = ["email", "organization", "role", "expires_at", "accepted_at", "revoked_at"] + list_filter = ["organization", "role"] + search_fields = ["email", "organization__name"] + readonly_fields = ["token_hash", "created_at", "accepted_at", "revoked_at"] + + def has_add_permission(self, request) -> bool: # noqa: ANN001 + return False + + def has_delete_permission(self, request, obj=None) -> bool: # noqa: ANN001 + return False + @admin.register(AuditEvent) class AuditEventAdmin(admin.ModelAdmin): diff --git a/apps/backend/hub_platform/identity/auth/common.py b/apps/backend/hub_platform/identity/auth/common.py index afe200f..b3361d1 100644 --- a/apps/backend/hub_platform/identity/auth/common.py +++ b/apps/backend/hub_platform/identity/auth/common.py @@ -1,12 +1,15 @@ from rest_framework.request import Request +from hub_platform.identity.membership_context import single_membership_for_user from hub_platform.identity.models import HumanUser from hub_platform.identity.policy import get_effective_access from hub_platform.identity.sessions import revoke_user_sessions def _user_payload(user: HumanUser) -> dict[str, object]: - profile = user.employee_profile + profile = single_membership_for_user(user) + if profile is None: + raise ValueError("An explicit organization context is required") payload = { "id": user.id, "email": user.email, @@ -16,16 +19,18 @@ def _user_payload(user: HumanUser) -> dict[str, object]: "organizationName": profile.organization.name, "organization": profile.organization.slug, "department": profile.primary_department.code if profile.primary_department else None, - "mustChangePassword": profile.must_change_password, + "mustChangePassword": user.must_change_password, "totpRequired": profile.totp_required, - "totpEnabled": profile.totp_enabled, + "totpEnabled": user.totp_enabled, } - payload.update(get_effective_access(user)) + payload.update(get_effective_access(profile)) return payload def _challenge_payload(user: HumanUser) -> dict[str, object]: - profile = user.employee_profile + profile = single_membership_for_user(user) + if profile is None: + raise ValueError("An explicit organization context is required") return { "email": user.email, "fullName": user.full_name, diff --git a/apps/backend/hub_platform/identity/auth/password_reset.py b/apps/backend/hub_platform/identity/auth/password_reset.py index 9e7416a..91f1f0e 100644 --- a/apps/backend/hub_platform/identity/auth/password_reset.py +++ b/apps/backend/hub_platform/identity/auth/password_reset.py @@ -38,8 +38,13 @@ class PasswordResetRequestView(APIView): email = HumanUser.objects.normalize_email(str(request.data.get("email", "")).strip()) if email: user = HumanUser.objects.filter(email__iexact=email, is_active=True).first() - profile = getattr(user, "employee_profile", None) if user is not None else None - if user is not None and profile is not None and not profile.is_blocked: + active_memberships = ( + user.memberships.filter(blocked_at__isnull=True).select_related("organization") + if user is not None + else None + ) + profile = active_memberships.first() if active_memberships is not None else None + if user is not None and profile is not None: enqueue_event( DomainEvent( aggregate_type="HumanUser", @@ -96,11 +101,10 @@ class PasswordResetConfirmView(APIView): return Response({"detail": " ".join(error.messages)}, status=400) user.set_password(new_password) - user.save(update_fields=["password"]) - profile = getattr(user, "employee_profile", None) - if profile is not None and profile.must_change_password: - profile.must_change_password = False - profile.save(update_fields=["must_change_password"]) + user.must_change_password = False + user.save(update_fields=["password", "must_change_password"]) + memberships = user.memberships.select_related("organization") + profile = memberships.first() if memberships.count() == 1 else None record_audit_event( action="identity.password_reset_completed", actor=user, diff --git a/apps/backend/hub_platform/identity/auth/profile.py b/apps/backend/hub_platform/identity/auth/profile.py index 10cc6f3..36f9909 100644 --- a/apps/backend/hub_platform/identity/auth/profile.py +++ b/apps/backend/hub_platform/identity/auth/profile.py @@ -75,9 +75,10 @@ class ProfileTotpStartView(APIView): def post(self, request: Request) -> Response: profile = request.user.employee_profile profile.totp_required = True - profile.totp_enabled = False - profile.totp_secret = "" - profile.save(update_fields=["totp_required", "totp_enabled", "totp_secret"]) + profile.save(update_fields=["totp_required"]) + request.user.totp_enabled = False + request.user.totp_secret = "" + request.user.save(update_fields=["totp_enabled", "totp_secret"]) record_audit_event( action="identity.profile_totp_setup_started", actor=request.user, @@ -97,9 +98,10 @@ class ProfileTotpDisableView(APIView): profile = request.user.employee_profile profile.totp_required = False - profile.totp_enabled = False - profile.totp_secret = "" - profile.save(update_fields=["totp_required", "totp_enabled", "totp_secret"]) + profile.save(update_fields=["totp_required"]) + request.user.totp_enabled = False + request.user.totp_secret = "" + request.user.save(update_fields=["totp_enabled", "totp_secret"]) revoked = _revoke_other_user_sessions(request) record_audit_event( action="identity.profile_totp_disabled", @@ -135,7 +137,7 @@ class ChangeTemporaryPasswordView(APIView): current_password = str(body.get("currentPassword", "")) new_password = str(body.get("newPassword", "")) profile = request.user.employee_profile - if not profile.must_change_password and not request.user.check_password(current_password): + if not request.user.must_change_password and not request.user.check_password(current_password): return Response({"detail": "Current password is invalid"}, status=400) try: validate_password(new_password, user=request.user) @@ -143,9 +145,8 @@ class ChangeTemporaryPasswordView(APIView): return Response({"detail": " ".join(error.messages)}, status=400) request.user.set_password(new_password) - request.user.save(update_fields=["password"]) - profile.must_change_password = False - profile.save(update_fields=["must_change_password"]) + request.user.must_change_password = False + request.user.save(update_fields=["password", "must_change_password"]) login(request, request.user) record_audit_event( action="identity.temporary_password_changed", diff --git a/apps/backend/hub_platform/identity/auth/sessions.py b/apps/backend/hub_platform/identity/auth/sessions.py index af7b191..0927e51 100644 --- a/apps/backend/hub_platform/identity/auth/sessions.py +++ b/apps/backend/hub_platform/identity/auth/sessions.py @@ -10,6 +10,7 @@ from rest_framework.views import APIView from hub_platform.identity.audit import record_audit_event from hub_platform.identity.auth.common import _challenge_payload, _user_payload from hub_platform.identity.auth.totp_utils import TOTP_SESSION_KEY +from hub_platform.identity.membership_context import single_membership_for_user from hub_platform.identity.models import AuditResult @@ -39,12 +40,15 @@ class LoginView(APIView): if user is None: record_audit_event(action="identity.login_failed", result=AuditResult.DENIED, request=request) return Response({"detail": "Invalid credentials"}, status=401) - if not hasattr(user, "employee_profile") or user.employee_profile.is_blocked: + profile = single_membership_for_user(user) + if profile is None: + record_audit_event(action="identity.login_blocked", actor=user, result=AuditResult.DENIED, request=request) + return Response({"detail": "Organization context is unavailable"}, status=403) + if profile.is_blocked: record_audit_event(action="identity.login_blocked", actor=user, result=AuditResult.DENIED, request=request) return Response({"detail": "Account is blocked"}, status=403) - profile = user.employee_profile - if profile.totp_enabled: + if user.totp_enabled: request.session[TOTP_SESSION_KEY] = user.id record_audit_event( action="identity.login_totp_required", @@ -76,7 +80,8 @@ class LogoutView(APIView): def post(self, request: Request) -> Response: user = request.user - organization = getattr(getattr(user, "employee_profile", None), "organization", None) + membership = single_membership_for_user(user) + organization = membership.organization if membership is not None else None logout(request) record_audit_event(action="identity.logout", actor=user, organization=organization, request=request) return Response({"authenticated": False}) diff --git a/apps/backend/hub_platform/identity/auth/totp.py b/apps/backend/hub_platform/identity/auth/totp.py index 7bf094e..e5df7a1 100644 --- a/apps/backend/hub_platform/identity/auth/totp.py +++ b/apps/backend/hub_platform/identity/auth/totp.py @@ -18,6 +18,7 @@ from hub_platform.identity.auth.totp_utils import ( _ensure_totp_secret, _verify_totp, ) +from hub_platform.identity.membership_context import single_membership_for_user from hub_platform.identity.models import AuditResult, HumanUser @@ -28,7 +29,7 @@ class TotpSetupView(APIView): profile = request.user.employee_profile if not profile.totp_required: return Response({"detail": "TOTP is not required"}, status=400) - if profile.totp_enabled: + if request.user.totp_enabled: return Response({"detail": "TOTP is already enabled"}, status=400) secret = _ensure_totp_secret(request.user) @@ -67,8 +68,8 @@ class TotpConfirmView(APIView): ) return Response({"detail": "Invalid TOTP code"}, status=400) - profile.totp_enabled = True - profile.save(update_fields=["totp_enabled"]) + request.user.totp_enabled = True + request.user.save(update_fields=["totp_enabled"]) record_audit_event( action="identity.totp_enabled", actor=request.user, @@ -91,18 +92,21 @@ class TotpVerifyView(APIView): return Response({"detail": "TOTP challenge is not active"}, status=401) try: - user = HumanUser.objects.select_related("employee_profile", "employee_profile__organization").get( + user = HumanUser.objects.prefetch_related("memberships__organization").get( id=pending_user_id ) except HumanUser.DoesNotExist: request.session.pop(TOTP_SESSION_KEY, None) return Response({"detail": "TOTP challenge is not active"}, status=401) - profile = user.employee_profile + profile = single_membership_for_user(user) + if profile is None or profile.is_blocked: + request.session.pop(TOTP_SESSION_KEY, None) + return Response({"detail": "TOTP challenge is not active"}, status=401) if ( - not profile.totp_enabled - or not profile.totp_secret - or not _verify_totp(profile.totp_secret, str(request.data.get("code", ""))) + not user.totp_enabled + or not user.totp_secret + or not _verify_totp(user.totp_secret, str(request.data.get("code", ""))) ): record_audit_event( action="identity.totp_verify_failed", diff --git a/apps/backend/hub_platform/identity/auth/totp_utils.py b/apps/backend/hub_platform/identity/auth/totp_utils.py index 70f303f..141338a 100644 --- a/apps/backend/hub_platform/identity/auth/totp_utils.py +++ b/apps/backend/hub_platform/identity/auth/totp_utils.py @@ -43,8 +43,7 @@ def _verify_totp(secret: str, code: str) -> bool: def _ensure_totp_secret(user: HumanUser) -> str: - profile = user.employee_profile - if not profile.totp_secret: - profile.totp_secret = _generate_totp_secret() - profile.save(update_fields=["totp_secret"]) - return profile.totp_secret + if not user.totp_secret: + user.totp_secret = _generate_totp_secret() + user.save(update_fields=["totp_secret"]) + return user.totp_secret diff --git a/apps/backend/hub_platform/identity/bootstrap.py b/apps/backend/hub_platform/identity/bootstrap.py index 334ba8c..ef828f8 100644 --- a/apps/backend/hub_platform/identity/bootstrap.py +++ b/apps/backend/hub_platform/identity/bootstrap.py @@ -68,8 +68,8 @@ def bootstrap_edevs_owner(*, email: str, password: str, full_name: str = "") -> owner_profile, _ = EmployeeProfile.objects.get_or_create( user=owner, + organization=organization, defaults={ - "organization": organization, "role": EmployeeRole.OWNER, "position_title": "Владелец", # OWNER всегда на уровне компании (ADR-HUB-0027): без основного отдела. @@ -92,8 +92,8 @@ def bootstrap_edevs_owner(*, email: str, password: str, full_name: str = "") -> operator_profile, _ = EmployeeProfile.objects.get_or_create( user=operator, + organization=organization, defaults={ - "organization": organization, "role": EmployeeRole.EMPLOYEE, "position_title": "Оператор отдела продаж", "phone": "+7 916 245 14 02", diff --git a/apps/backend/hub_platform/identity/employee_security_views.py b/apps/backend/hub_platform/identity/employee_security_views.py index f4c008f..fcb7e79 100644 --- a/apps/backend/hub_platform/identity/employee_security_views.py +++ b/apps/backend/hub_platform/identity/employee_security_views.py @@ -5,10 +5,9 @@ from rest_framework.response import Response from rest_framework.views import APIView from hub_platform.identity.audit import record_audit_event -from hub_platform.identity.employee_support import employee_payload, get_owned_profile, temporary_password +from hub_platform.identity.employee_support import employee_payload, get_owned_profile from hub_platform.identity.employee_validation import deny_employee_action from hub_platform.identity.governance import EmployeeAction, can_manage_employee -from hub_platform.identity.sessions import revoke_user_sessions class EmployeeResetPasswordView(APIView): @@ -20,23 +19,7 @@ class EmployeeResetPasswordView(APIView): profile = get_owned_profile(request, user_id) if profile is None: return Response({"detail": "Employee not found"}, status=404) - if not can_manage_employee(actor, profile, EmployeeAction.RESET_PASSWORD): - return deny_employee_action(request, profile, EmployeeAction.RESET_PASSWORD) - password = temporary_password() - profile.user.set_password(password) - profile.user.save(update_fields=["password"]) - profile.must_change_password = True - profile.save(update_fields=["must_change_password"]) - revoke_user_sessions(profile.user_id) - record_audit_event( - action="identity.employee_password_reset", - actor=request.user, - organization=profile.organization, - object_type="HumanUser", - object_id=str(profile.user_id), - request=request, - ) - return Response({"employee": employee_payload(profile, actor), "temporaryPassword": password}) + return deny_employee_action(request, profile, EmployeeAction.RESET_PASSWORD) class EmployeeRevokeSessionsView(APIView): @@ -47,19 +30,7 @@ class EmployeeRevokeSessionsView(APIView): profile = get_owned_profile(request, user_id) if profile is None: return Response({"detail": "Employee not found"}, status=404) - if not can_manage_employee(actor, profile, EmployeeAction.TERMINATE_SESSIONS): - return deny_employee_action(request, profile, EmployeeAction.TERMINATE_SESSIONS) - revoked = revoke_user_sessions(profile.user_id) - record_audit_event( - action="identity.employee_sessions_terminated", - actor=request.user, - organization=profile.organization, - object_type="HumanUser", - object_id=str(profile.user_id), - payload={"revoked": revoked}, - request=request, - ) - return Response({"employee": employee_payload(profile, actor), "revoked": revoked}) + return deny_employee_action(request, profile, EmployeeAction.TERMINATE_SESSIONS) class EmployeeBlockView(APIView): @@ -74,7 +45,6 @@ class EmployeeBlockView(APIView): if not can_manage_employee(actor, profile, EmployeeAction.BLOCK): return deny_employee_action(request, profile, EmployeeAction.BLOCK) profile.block() - revoke_user_sessions(profile.user_id) record_audit_event( action="identity.employee_blocked", actor=request.user, @@ -97,10 +67,7 @@ class EmployeeUnblockView(APIView): return Response({"detail": "Employee not found"}, status=404) if not can_manage_employee(actor, profile, EmployeeAction.UNBLOCK): return deny_employee_action(request, profile, EmployeeAction.UNBLOCK) - profile.blocked_at = None - profile.user.is_active = True - profile.user.save(update_fields=["is_active"]) - profile.save(update_fields=["blocked_at"]) + profile.unblock() record_audit_event( action="identity.employee_unblocked", actor=request.user, @@ -110,4 +77,3 @@ class EmployeeUnblockView(APIView): request=request, ) return Response({"employee": employee_payload(profile, actor)}) - diff --git a/apps/backend/hub_platform/identity/employee_support.py b/apps/backend/hub_platform/identity/employee_support.py index 399c68d..f14eea5 100644 --- a/apps/backend/hub_platform/identity/employee_support.py +++ b/apps/backend/hub_platform/identity/employee_support.py @@ -1,16 +1,13 @@ -import secrets -import string - from rest_framework.request import Request from hub_platform.identity.governance import employee_management_flags -from hub_platform.identity.models import AuditEvent, EmployeeProfile +from hub_platform.identity.models import AuditEvent, OrganizationMembership from hub_platform.identity.sessions import count_user_sessions def employee_payload( - profile: EmployeeProfile, - actor: EmployeeProfile | None = None, + profile: OrganizationMembership, + actor: OrganizationMembership | None = None, *, include_detail: bool = False, ) -> dict[str, object]: @@ -27,9 +24,9 @@ def employee_payload( "lastLogin": profile.user.last_login.isoformat() if profile.user.last_login else None, "isActive": profile.user.is_active, "isBlocked": profile.is_blocked, - "mustChangePassword": profile.must_change_password, + "mustChangePassword": profile.user.must_change_password, "totpRequired": profile.totp_required, - "totpEnabled": profile.totp_enabled, + "totpEnabled": profile.user.totp_enabled, "accessAssignments": [ { "id": assignment.id, @@ -71,18 +68,13 @@ def employee_payload( return payload -def get_owned_profile(request: Request, user_id: int) -> EmployeeProfile | None: +def get_owned_profile(request: Request, user_id: int) -> OrganizationMembership | None: owner_profile = request.user.employee_profile try: return ( - EmployeeProfile.objects.select_related("user", "primary_department") + OrganizationMembership.objects.select_related("user", "primary_department") .prefetch_related("access_assignments__access_profile__capability_links") .get(user_id=user_id, organization=owner_profile.organization) ) - except EmployeeProfile.DoesNotExist: + except OrganizationMembership.DoesNotExist: return None - - -def temporary_password() -> str: - alphabet = string.ascii_letters + string.digits - return "Temp-" + "".join(secrets.choice(alphabet) for _ in range(14)) + "!" diff --git a/apps/backend/hub_platform/identity/employee_views.py b/apps/backend/hub_platform/identity/employee_views.py index d37a9d6..0b9e5dd 100644 --- a/apps/backend/hub_platform/identity/employee_views.py +++ b/apps/backend/hub_platform/identity/employee_views.py @@ -24,7 +24,6 @@ from hub_platform.identity.policy import ( authorize, has_capability_any_scope, ) -from hub_platform.identity.sessions import revoke_user_sessions class EmployeeListView(APIView): @@ -85,11 +84,8 @@ class EmployeeCreateView(APIView): return Response({"detail": "ADMIN access is defined by the system role"}, status=400) if position_error: return Response({"detail": position_error}, status=400) - if provided_password and len(provided_password) < 12: - return Response( - {"detail": "Temporary password must contain at least 12 characters"}, - status=400, - ) + if provided_password: + return Response({"detail": "Temporary passwords are not supported"}, status=400) if HumanUser.objects.filter(email=email).exists(): return Response({"detail": "Email is already used"}, status=400) @@ -101,10 +97,11 @@ class EmployeeCreateView(APIView): user = HumanUser.objects.create_user( email=email, - password=provided_password or None, + password=None, full_name=full_name, is_staff=False, is_superuser=False, + must_change_password=True, ) profile = EmployeeProfile.objects.create( user=user, @@ -113,7 +110,6 @@ class EmployeeCreateView(APIView): position_title=position_title, phone=phone, primary_department=department, - must_change_password=True, ) try: for assignment in assignments: @@ -130,15 +126,14 @@ class EmployeeCreateView(APIView): payload={"role": requested_role}, request=request, ) - if not provided_password: - enqueue_event( - DomainEvent( - aggregate_type="HumanUser", - aggregate_id=str(user.id), - event_type=INITIAL_ACCESS_REQUESTED, - payload={"userId": user.id}, - ) + enqueue_event( + DomainEvent( + aggregate_type="HumanUser", + aggregate_id=str(user.id), + event_type=INITIAL_ACCESS_REQUESTED, + payload={"userId": user.id}, ) + ) return Response({"employee": employee_payload(profile, actor)}, status=201) @@ -179,7 +174,6 @@ class EmployeeUpdateView(APIView): profile.primary_department.code if profile.primary_department else "" ) requested_role = str(body.get("role", profile.role)) - totp_enabled = body.get("totpEnabled", profile.totp_enabled) if not full_name: return Response({"detail": "Full name is required"}, status=400) @@ -217,17 +211,12 @@ class EmployeeUpdateView(APIView): profile.position_title = position_title profile.role = requested_role profile.primary_department = department - profile.totp_enabled = bool(totp_enabled) - if not profile.totp_enabled: - profile.totp_secret = "" profile.save( update_fields=[ "phone", "position_title", "role", "primary_department", - "totp_enabled", - "totp_secret", ] ) @@ -249,7 +238,6 @@ class EmployeeUpdateView(APIView): payload={"role": profile.role}, request=request, ) - revoke_user_sessions(profile.user_id) if placement_changing: record_audit_event( action="identity.employee_placement_changed", diff --git a/apps/backend/hub_platform/identity/event_handlers.py b/apps/backend/hub_platform/identity/event_handlers.py index 0d517d3..121b107 100644 --- a/apps/backend/hub_platform/identity/event_handlers.py +++ b/apps/backend/hub_platform/identity/event_handlers.py @@ -9,11 +9,12 @@ PASSWORD_RESET_REQUESTED = "identity.password_reset_requested" def _active_user(payload: dict) -> HumanUser | None: user = ( HumanUser.objects.filter(pk=payload.get("userId"), is_active=True) - .select_related("employee_profile") + .prefetch_related("memberships") .first() ) - profile = getattr(user, "employee_profile", None) if user is not None else None - return user if user is not None and profile is not None and not profile.is_blocked else None + if user is None: + return None + return user if user.memberships.filter(blocked_at__isnull=True).exists() else None @register(INITIAL_ACCESS_REQUESTED) diff --git a/apps/backend/hub_platform/identity/governance.py b/apps/backend/hub_platform/identity/governance.py index f16a750..57c93d8 100644 --- a/apps/backend/hub_platform/identity/governance.py +++ b/apps/backend/hub_platform/identity/governance.py @@ -12,7 +12,7 @@ from __future__ import annotations -from hub_platform.identity.models import EmployeeProfile, EmployeeRole +from hub_platform.identity.models import EmployeeRole, OrganizationMembership class EmployeeAction: @@ -40,14 +40,12 @@ _TARGET_ACTIONS = frozenset( EmployeeAction.CHANGE_ACCESS, EmployeeAction.BLOCK, EmployeeAction.UNBLOCK, - EmployeeAction.RESET_PASSWORD, - EmployeeAction.TERMINATE_SESSIONS, EmployeeAction.DELETE, } ) -def _is_active_manager(actor: EmployeeProfile | None) -> bool: +def _is_active_manager(actor: OrganizationMembership | None) -> bool: return ( actor is not None and not actor.is_blocked @@ -55,7 +53,7 @@ def _is_active_manager(actor: EmployeeProfile | None) -> bool: ) -def can_create_role(actor: EmployeeProfile | None, new_role: str) -> bool: +def can_create_role(actor: OrganizationMembership | None, new_role: str) -> bool: """Кого actor вправе создать. OWNER — ADMIN или EMPLOYEE; ADMIN — только EMPLOYEE. Второй OWNER через обычный create не создаётся (инвариант ровно одного владельца).""" @@ -71,8 +69,8 @@ def can_create_role(actor: EmployeeProfile | None, new_role: str) -> bool: def can_manage_employee( - actor: EmployeeProfile | None, - target: EmployeeProfile | None, + actor: OrganizationMembership | None, + target: OrganizationMembership | None, action: str, ) -> bool: """Может ли actor выполнить action над target (SPEC-HUB-0016 §8). @@ -112,8 +110,8 @@ def can_manage_employee( def employee_management_flags( - actor: EmployeeProfile | None, - target: EmployeeProfile, + actor: OrganizationMembership | None, + target: OrganizationMembership, ) -> dict[str, bool]: """Флаги доступных действий над target для actor — backend как источник истины для скрытия недоступных действий во фронтенде (ADR-HUB-0027).""" diff --git a/apps/backend/hub_platform/identity/invitation_models.py b/apps/backend/hub_platform/identity/invitation_models.py new file mode 100644 index 0000000..81471e8 --- /dev/null +++ b/apps/backend/hub_platform/identity/invitation_models.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from django.core.exceptions import ValidationError +from django.db import models +from django.db.models import Q +from django.db.models.functions import Lower +from django.utils import timezone + +from hub_platform.identity.models import EmployeeRole, Organization, OrganizationMembership + + +class OrganizationInvitation(models.Model): + """One-time membership invitation; plaintext tokens and passwords are never stored.""" + + organization = models.ForeignKey( + Organization, + on_delete=models.PROTECT, + related_name="invitations", + ) + email = models.EmailField() + role = models.CharField(max_length=32, choices=EmployeeRole.choices) + token_hash = models.CharField(max_length=128, unique=True) + expires_at = models.DateTimeField() + created_by = models.ForeignKey( + OrganizationMembership, + on_delete=models.PROTECT, + related_name="invitations_created", + null=True, + blank=True, + ) + accepted_at = models.DateTimeField(null=True, blank=True) + revoked_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + Lower("email"), + "organization", + condition=Q(accepted_at__isnull=True, revoked_at__isnull=True), + name="uniq_pending_invitation_org_email", + ) + ] + + def __str__(self) -> str: + return f"{self.organization.slug}:{self.email}:{self.role}" + + def save(self, *args, **kwargs) -> None: + self.email = self.email.strip().lower() + self.full_clean() + super().save(*args, **kwargs) + + def clean(self) -> None: + if self.created_by_id and self.created_by.organization_id != self.organization_id: + raise ValidationError("Invitation creator must belong to the same organization") + if self._state.adding and self.expires_at <= timezone.now(): + raise ValidationError({"expires_at": "Invitation expiry must be in the future"}) + + @property + def is_pending(self) -> bool: + return ( + self.accepted_at is None + and self.revoked_at is None + and self.expires_at > timezone.now() + ) diff --git a/apps/backend/hub_platform/identity/invitation_service.py b/apps/backend/hub_platform/identity/invitation_service.py new file mode 100644 index 0000000..921e928 --- /dev/null +++ b/apps/backend/hub_platform/identity/invitation_service.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import hashlib +import secrets +from dataclasses import dataclass +from datetime import datetime + +from django.db import transaction +from django.utils import timezone + +from hub_platform.identity.models import ( + Organization, + OrganizationInvitation, + OrganizationMembership, +) + + +def _token_hash(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +@dataclass(frozen=True, slots=True) +class IssuedInvitation: + invitation: OrganizationInvitation + token: str + + +@transaction.atomic +def issue_invitation( + *, + organization: Organization, + email: str, + role: str, + expires_at: datetime, + created_by: OrganizationMembership | 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( + organization=organization, + email=normalized_email, + role=role, + token_hash=_token_hash(token), + expires_at=expires_at, + created_by=created_by, + ) + return IssuedInvitation(invitation=invitation, token=token) + + +def pending_invitation_for_token(token: str) -> OrganizationInvitation | None: + if not token: + return None + return OrganizationInvitation.objects.filter( + token_hash=_token_hash(token), + accepted_at__isnull=True, + revoked_at__isnull=True, + expires_at__gt=timezone.now(), + ).first() diff --git a/apps/backend/hub_platform/identity/management/commands/seed_hub_initial_data.py b/apps/backend/hub_platform/identity/management/commands/seed_hub_initial_data.py index c98c2aa..3baf2f6 100644 --- a/apps/backend/hub_platform/identity/management/commands/seed_hub_initial_data.py +++ b/apps/backend/hub_platform/identity/management/commands/seed_hub_initial_data.py @@ -70,7 +70,7 @@ def _seed_core(*, owner_email: str, owner_password: str, owner_name: str) -> Cor owner = None created_owner = False existing_owner = ( - organization.employees.filter(role=EmployeeRole.OWNER).select_related("user").first() + organization.memberships.filter(role=EmployeeRole.OWNER).select_related("user").first() ) if owner_email: normalized_email = HumanUser.objects.normalize_email(owner_email) @@ -96,8 +96,8 @@ def _seed_core(*, owner_email: str, owner_password: str, owner_name: str) -> Cor owner.save(update_fields=changed_fields) EmployeeProfile.objects.update_or_create( user=owner, + organization=organization, defaults={ - "organization": organization, "role": EmployeeRole.OWNER, "position_title": "Владелец", # OWNER всегда на уровне компании (ADR-HUB-0027). @@ -199,7 +199,7 @@ class Command(BaseCommand): if ( owner_email and not owner_password - and not Organization.objects.filter(employees__role=EmployeeRole.OWNER).exists() + and not Organization.objects.filter(memberships__role=EmployeeRole.OWNER).exists() ): raise CommandError("HUB_SEED_OWNER_PASSWORD is required when creating the first OWNER.") diff --git a/apps/backend/hub_platform/identity/management/commands/verify_employee_authorization.py b/apps/backend/hub_platform/identity/management/commands/verify_employee_authorization.py index 51c615b..883aa2d 100644 --- a/apps/backend/hub_platform/identity/management/commands/verify_employee_authorization.py +++ b/apps/backend/hub_platform/identity/management/commands/verify_employee_authorization.py @@ -23,7 +23,7 @@ class Command(BaseCommand): invalid_owner_organizations = list( Organization.objects.annotate( owner_count=Count( - "employees", filter=Q(employees__role=EmployeeRole.OWNER) + "memberships", filter=Q(memberships__role=EmployeeRole.OWNER) ) ) .exclude(owner_count=1) diff --git a/apps/backend/hub_platform/identity/membership_context.py b/apps/backend/hub_platform/identity/membership_context.py new file mode 100644 index 0000000..002d687 --- /dev/null +++ b/apps/backend/hub_platform/identity/membership_context.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from hub_platform.identity.models import HumanUser, OrganizationMembership + + +def single_membership_for_user(user: HumanUser) -> OrganizationMembership | None: + """C02 bridge for routes that do not yet carry an explicit tenant context. + + Returning ``None`` for both zero and multiple memberships keeps legacy routes + fail-closed. C03 replaces this bridge with an explicit organization route. + """ + + try: + return user.memberships.select_related("organization", "primary_department").get() + except ( + OrganizationMembership.DoesNotExist, + OrganizationMembership.MultipleObjectsReturned, + ): + return None diff --git a/apps/backend/hub_platform/identity/migrations/0012_membership_identity.py b/apps/backend/hub_platform/identity/migrations/0012_membership_identity.py new file mode 100644 index 0000000..57342e8 --- /dev/null +++ b/apps/backend/hub_platform/identity/migrations/0012_membership_identity.py @@ -0,0 +1,190 @@ +import uuid + +import django.db.models.deletion +import django.db.models.functions.text +from django.conf import settings +from django.db import migrations, models + +import hub_platform.identity.crypto + + +def backfill_organization_public_ids(apps, schema_editor): + Organization = apps.get_model("identity", "Organization") + for organization in Organization.objects.filter(public_id__isnull=True).iterator(): + Organization.objects.filter(pk=organization.pk).update(public_id=uuid.uuid4()) + + +def copy_security_state_to_users(apps, schema_editor): + EmployeeProfile = apps.get_model("identity", "EmployeeProfile") + HumanUser = apps.get_model("identity", "HumanUser") + for profile in EmployeeProfile.objects.all().iterator(): + HumanUser.objects.filter(pk=profile.user_id).update( + must_change_password=profile.must_change_password, + totp_enabled=profile.totp_enabled, + totp_secret=profile.totp_secret, + ) + + +def copy_security_state_to_profiles(apps, schema_editor): + EmployeeProfile = apps.get_model("identity", "EmployeeProfile") + for profile in EmployeeProfile.objects.select_related("user").all().iterator(): + EmployeeProfile.objects.filter(pk=profile.pk).update( + must_change_password=profile.user.must_change_password, + totp_enabled=profile.user.totp_enabled, + totp_secret=profile.user.totp_secret, + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("identity", "0011_enforce_capability_registry"), + ] + + operations = [ + migrations.AddField( + model_name="humanuser", + name="must_change_password", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="humanuser", + name="totp_enabled", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="humanuser", + name="totp_secret", + field=hub_platform.identity.crypto.EncryptedCharField(blank=True, max_length=255), + ), + migrations.AddField( + model_name="organization", + name="public_id", + field=models.UUIDField(editable=False, null=True), + ), + migrations.RunPython(backfill_organization_public_ids, migrations.RunPython.noop), + migrations.AlterField( + model_name="organization", + name="public_id", + field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + migrations.RunPython(copy_security_state_to_users, copy_security_state_to_profiles), + migrations.RemoveField( + model_name="employeeprofile", + name="must_change_password", + ), + migrations.RemoveField( + model_name="employeeprofile", + name="totp_enabled", + ), + migrations.RemoveField( + model_name="employeeprofile", + name="totp_secret", + ), + migrations.SeparateDatabaseAndState( + database_operations=[], + state_operations=[ + migrations.RenameModel( + old_name="EmployeeProfile", + new_name="OrganizationMembership", + ), + migrations.AlterModelTable( + name="organizationmembership", + table="identity_employeeprofile", + ), + ], + ), + migrations.AlterField( + model_name="organizationmembership", + name="user", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="memberships", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="organizationmembership", + name="organization", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="memberships", + to="identity.organization", + ), + ), + migrations.AlterField( + model_name="organizationmembership", + name="primary_department", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="memberships", + to="identity.department", + ), + ), + migrations.AddConstraint( + model_name="organizationmembership", + constraint=models.UniqueConstraint( + fields=("user", "organization"), + name="uniq_membership_user_organization", + ), + ), + migrations.CreateModel( + name="OrganizationInvitation", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("email", models.EmailField(max_length=254)), + ( + "role", + models.CharField( + choices=[ + ("OWNER", "Owner"), + ("ADMIN", "Admin"), + ("EMPLOYEE", "Employee"), + ], + max_length=32, + ), + ), + ("token_hash", models.CharField(max_length=128, unique=True)), + ("expires_at", models.DateTimeField()), + ("accepted_at", models.DateTimeField(blank=True, null=True)), + ("revoked_at", models.DateTimeField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="invitations_created", + to="identity.organizationmembership", + ), + ), + ( + "organization", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="invitations", + to="identity.organization", + ), + ), + ], + ), + migrations.AddConstraint( + model_name="organizationinvitation", + constraint=models.UniqueConstraint( + django.db.models.functions.text.Lower("email"), + models.F("organization"), + condition=models.Q(accepted_at__isnull=True, revoked_at__isnull=True), + name="uniq_pending_invitation_org_email", + ), + ), + ] diff --git a/apps/backend/hub_platform/identity/models.py b/apps/backend/hub_platform/identity/models.py index ee45cc8..e263c77 100644 --- a/apps/backend/hub_platform/identity/models.py +++ b/apps/backend/hub_platform/identity/models.py @@ -1,7 +1,10 @@ from __future__ import annotations +import uuid + from django.conf import settings from django.contrib.auth.models import AbstractUser, UserManager +from django.core.exceptions import ValidationError from django.db import models from django.db.models import Q from django.utils import timezone @@ -40,6 +43,9 @@ class HumanUser(AbstractUser): username = None email = models.EmailField(unique=True) full_name = models.CharField(max_length=255, blank=True) + must_change_password = models.BooleanField(default=False) + totp_enabled = models.BooleanField(default=False) + totp_secret = EncryptedCharField(max_length=255, blank=True) USERNAME_FIELD = "email" REQUIRED_FIELDS: list[str] = [] @@ -48,6 +54,18 @@ class HumanUser(AbstractUser): def __str__(self) -> str: return self.email + @property + def employee_profile(self) -> "OrganizationMembership": + """C02 compatibility for legacy unscoped routes; removed by C03. + + Never guesses a tenant when a user has multiple memberships. + """ + + try: + return self.memberships.get() + except OrganizationMembership.DoesNotExist as error: + raise AttributeError("User has no organization membership") from error + class TaxRegime(models.TextChoices): USN_INCOME = "USN_INCOME", "УСН доходы" @@ -58,6 +76,7 @@ class VatMode(models.TextChoices): class Organization(models.Model): + public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False) name = models.CharField(max_length=255) slug = models.SlugField(unique=True) timezone = models.CharField(max_length=64, default="Europe/Moscow") @@ -69,6 +88,11 @@ class Organization(models.Model): def __str__(self) -> str: return self.name + def save(self, *args, **kwargs) -> None: + if self.pk and type(self).objects.filter(pk=self.pk).exclude(public_id=self.public_id).exists(): + raise ValidationError({"public_id": "Organization public_id is immutable"}) + super().save(*args, **kwargs) + class DepartmentStatus(models.TextChoices): ACTIVE = "ACTIVE", "Active" @@ -105,9 +129,17 @@ class EmployeeRole(models.TextChoices): EMPLOYEE = "EMPLOYEE", "Employee" -class EmployeeProfile(models.Model): - user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="employee_profile") - organization = models.ForeignKey(Organization, on_delete=models.PROTECT, related_name="employees") +class OrganizationMembership(models.Model): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="memberships", + ) + organization = models.ForeignKey( + Organization, + on_delete=models.PROTECT, + related_name="memberships", + ) role = models.CharField(max_length=32, choices=EmployeeRole.choices) # Должность вводится вручную; обязательна для новых записей (SPEC-HUB-0016 §5). # Пустая строка допускается на уровне БД только для legacy-записей до backfill. @@ -118,19 +150,22 @@ class EmployeeProfile(models.Model): primary_department = models.ForeignKey( Department, on_delete=models.PROTECT, - related_name="employees", + related_name="memberships", null=True, blank=True, ) - must_change_password = models.BooleanField(default=False) totp_required = models.BooleanField(default=False) - totp_enabled = models.BooleanField(default=False) - totp_secret = EncryptedCharField(max_length=255, blank=True) blocked_at = models.DateTimeField(null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) class Meta: + # Preserve the deployed table and every existing PK/FK during C02 rename. + db_table = "identity_employeeprofile" constraints = [ + models.UniqueConstraint( + fields=["user", "organization"], + name="uniq_membership_user_organization", + ), # OWNER всегда на уровне компании (ADR-HUB-0027, инварианты размещения). models.CheckConstraint( condition=~Q(role=EmployeeRole.OWNER) | Q(primary_department__isnull=True), @@ -144,18 +179,25 @@ class EmployeeProfile(models.Model): ), ] + def __str__(self) -> str: + return f"{self.organization.slug}:{self.user.email}:{self.role}" + @property def is_blocked(self) -> bool: return self.blocked_at is not None def block(self) -> None: self.blocked_at = timezone.now() - self.user.is_active = False - self.user.save(update_fields=["is_active"]) self.save(update_fields=["blocked_at"]) - def __str__(self) -> str: - return f"{self.user.email}:{self.role}" + def unblock(self) -> None: + self.blocked_at = None + self.save(update_fields=["blocked_at"]) + + +# Transitional import compatibility only. There is no second EmployeeProfile model/table. +# Runtime access through user.employee_profile is intentionally single-membership-only. +EmployeeProfile = OrganizationMembership class AuditResult(models.TextChoices): @@ -207,3 +249,6 @@ from hub_platform.identity.access_models import ( # noqa: E402, F401 AccessProfileCapability, EmployeeAccessAssignment, ) +from hub_platform.identity.invitation_models import ( # noqa: E402, F401 + OrganizationInvitation, +) diff --git a/apps/backend/hub_platform/identity/ownership_views.py b/apps/backend/hub_platform/identity/ownership_views.py index c849351..623b63b 100644 --- a/apps/backend/hub_platform/identity/ownership_views.py +++ b/apps/backend/hub_platform/identity/ownership_views.py @@ -13,7 +13,6 @@ from hub_platform.identity.employee_validation import ( ) from hub_platform.identity.governance import EmployeeAction, can_manage_employee from hub_platform.identity.models import EmployeeProfile, EmployeeRole -from hub_platform.identity.sessions import revoke_user_sessions class OwnershipTransferView(APIView): @@ -72,8 +71,6 @@ class OwnershipTransferView(APIView): }, request=request, ) - revoke_user_sessions(target.user_id) - revoke_user_sessions(actor.user_id) return Response( { "employee": employee_payload(target, actor), diff --git a/apps/backend/hub_platform/identity/policy.py b/apps/backend/hub_platform/identity/policy.py index f1b52a7..82549fd 100644 --- a/apps/backend/hub_platform/identity/policy.py +++ b/apps/backend/hub_platform/identity/policy.py @@ -10,7 +10,11 @@ from hub_platform.identity.capabilities import ( ScopeType, capability_spec, ) -from hub_platform.identity.models import EmployeeAccessAssignment, EmployeeProfile, EmployeeRole +from hub_platform.identity.models import ( + EmployeeAccessAssignment, + EmployeeRole, + OrganizationMembership, +) @dataclass(frozen=True, slots=True) @@ -19,16 +23,30 @@ class ResourceScope: department_id: int | None = None -def _active_profile(actor) -> EmployeeProfile | None: +def _active_membership(actor) -> OrganizationMembership | None: + """Resolve the authorization actor without guessing between organizations. + + C02 services can pass a membership directly. Legacy user-based routes remain + available only while the user has exactly one membership and are removed in C03. + """ + + if isinstance(actor, OrganizationMembership): + if not actor.user.is_active or actor.is_blocked: + return None + return actor if not getattr(actor, "is_authenticated", False) or not getattr(actor, "is_active", False): return None - profile = getattr(actor, "employee_profile", None) - if profile is None or profile.is_blocked: + try: + membership = actor.memberships.get() + except ( + OrganizationMembership.DoesNotExist, + OrganizationMembership.MultipleObjectsReturned, + ): return None - return profile + return None if membership.is_blocked else membership -def _assignments(profile: EmployeeProfile) -> QuerySet[EmployeeAccessAssignment]: +def _assignments(profile: OrganizationMembership) -> QuerySet[EmployeeAccessAssignment]: return ( EmployeeAccessAssignment.objects.filter( employee=profile, @@ -47,7 +65,7 @@ def authorize(actor, capability: str, resource_scope: ResourceScope) -> bool: except ValueError: return False - profile = _active_profile(actor) + profile = _active_membership(actor) if profile is None or profile.organization_id != resource_scope.organization_id: return False if profile.role == EmployeeRole.OWNER: @@ -74,7 +92,7 @@ def authorize(actor, capability: str, resource_scope: ResourceScope) -> bool: def has_capability_any_scope(actor, capability: str) -> bool: - profile = _active_profile(actor) + profile = _active_membership(actor) if profile is None: return False if profile.role == EmployeeRole.OWNER: @@ -89,13 +107,13 @@ def has_capability_any_scope(actor, capability: str) -> bool: def can_administer_access(actor) -> bool: - profile = _active_profile(actor) + profile = _active_membership(actor) return profile is not None and profile.role in {EmployeeRole.OWNER, EmployeeRole.ADMIN} def accessible_department_ids(actor, capability: str) -> set[int] | None: """None means all departments in the actor organization; set() means no access.""" - profile = _active_profile(actor) + profile = _active_membership(actor) if profile is None: return set() if profile.role == EmployeeRole.OWNER: @@ -117,7 +135,7 @@ def accessible_department_ids(actor, capability: str) -> set[int] | None: def get_effective_access(actor) -> dict[str, object]: - profile = _active_profile(actor) + profile = _active_membership(actor) if profile is None: return {"capabilities": [], "accessScopes": []} diff --git a/apps/backend/hub_platform/identity/test_access_api.py b/apps/backend/hub_platform/identity/test_access_api.py index aa1591f..4d2627f 100644 --- a/apps/backend/hub_platform/identity/test_access_api.py +++ b/apps/backend/hub_platform/identity/test_access_api.py @@ -206,7 +206,6 @@ class AccessManagementApiTests(TestCase): { "email": "new@access.test", "fullName": "New Employee", - "temporaryPassword": "Temporary-123", "positionTitle": "Specialist", "role": "EMPLOYEE", "department": "sales", @@ -240,7 +239,7 @@ class AccessManagementApiTests(TestCase): self.assertEqual(response.status_code, 201) created = HumanUser.objects.get(email="invited@access.test") self.assertFalse(created.has_usable_password()) - self.assertTrue(created.employee_profile.must_change_password) + self.assertTrue(created.must_change_password) self.assertTrue( OutboxEvent.objects.filter( aggregate_id=str(created.id), diff --git a/apps/backend/hub_platform/identity/test_membership_identity.py b/apps/backend/hub_platform/identity/test_membership_identity.py new file mode 100644 index 0000000..2d8aa52 --- /dev/null +++ b/apps/backend/hub_platform/identity/test_membership_identity.py @@ -0,0 +1,155 @@ +from datetime import timedelta +from uuid import UUID + +from django.core.exceptions import FieldDoesNotExist, ValidationError +from django.db import IntegrityError, transaction +from django.test import TestCase +from django.utils import timezone + +from hub_platform.identity.invitation_service import ( + issue_invitation, + pending_invitation_for_token, +) +from hub_platform.identity.models import ( + EmployeeAccessAssignment, + EmployeeProfile, + EmployeeRole, + HumanUser, + Organization, + OrganizationInvitation, + OrganizationMembership, +) +from hub_platform.identity.policy import ResourceScope, authorize + + +class MembershipIdentityTests(TestCase): + def setUp(self) -> None: + self.first_organization = Organization.objects.create(name="First", slug="first") + self.second_organization = Organization.objects.create(name="Second", slug="second") + self.user = HumanUser.objects.create_user( + email="member@example.test", + password="Password-123", + ) + self.first_membership = OrganizationMembership.objects.create( + user=self.user, + organization=self.first_organization, + role=EmployeeRole.ADMIN, + position_title="Administrator", + ) + + def test_organization_public_id_is_unique_uuid_and_immutable(self) -> None: + self.assertIsInstance(self.first_organization.public_id, UUID) + self.assertNotEqual( + self.first_organization.public_id, + self.second_organization.public_id, + ) + self.first_organization.public_id = self.second_organization.public_id + with self.assertRaises(ValidationError): + self.first_organization.save() + + def test_one_user_can_have_different_roles_in_two_organizations(self) -> None: + second_membership = OrganizationMembership.objects.create( + user=self.user, + organization=self.second_organization, + role=EmployeeRole.EMPLOYEE, + position_title="Specialist", + ) + self.assertEqual(self.user.memberships.count(), 2) + self.assertEqual(self.first_membership.role, EmployeeRole.ADMIN) + self.assertEqual(second_membership.role, EmployeeRole.EMPLOYEE) + + def test_duplicate_membership_is_rejected(self) -> None: + with self.assertRaises(IntegrityError), transaction.atomic(): + OrganizationMembership.objects.create( + user=self.user, + organization=self.first_organization, + role=EmployeeRole.EMPLOYEE, + position_title="Duplicate", + ) + + def test_membership_block_does_not_change_global_user_or_other_membership(self) -> None: + second_membership = OrganizationMembership.objects.create( + user=self.user, + organization=self.second_organization, + role=EmployeeRole.EMPLOYEE, + position_title="Specialist", + ) + self.first_membership.block() + self.user.refresh_from_db() + second_membership.refresh_from_db() + self.assertTrue(self.user.is_active) + self.assertTrue(self.first_membership.is_blocked) + self.assertFalse(second_membership.is_blocked) + + def test_security_credentials_are_global_user_fields(self) -> None: + self.user.must_change_password = True + self.user.totp_enabled = True + self.user.totp_secret = "JBSWY3DPEHPK3PXP" + self.user.save(update_fields=["must_change_password", "totp_enabled", "totp_secret"]) + for field_name in ("must_change_password", "totp_enabled", "totp_secret"): + with self.assertRaises(FieldDoesNotExist): + OrganizationMembership._meta.get_field(field_name) + + def test_employee_profile_is_only_a_transitional_python_alias(self) -> None: + self.assertIs(EmployeeProfile, OrganizationMembership) + self.assertEqual(OrganizationMembership._meta.db_table, "identity_employeeprofile") + + def test_legacy_user_lookup_refuses_to_guess_between_memberships(self) -> None: + self.assertEqual(self.user.employee_profile, self.first_membership) + OrganizationMembership.objects.create( + user=self.user, + organization=self.second_organization, + role=EmployeeRole.EMPLOYEE, + position_title="Specialist", + ) + with self.assertRaises(OrganizationMembership.MultipleObjectsReturned): + _ = self.user.employee_profile + + def test_policy_authorizes_an_explicit_membership(self) -> None: + self.assertTrue( + authorize( + self.first_membership, + "integrations.manage", + ResourceScope(self.first_organization.id), + ) + ) + self.assertFalse( + authorize( + self.first_membership, + "integrations.manage", + ResourceScope(self.second_organization.id), + ) + ) + + def test_access_assignment_targets_membership_model(self) -> None: + self.assertIs( + EmployeeAccessAssignment._meta.get_field("employee").remote_field.model, + OrganizationMembership, + ) + + def test_invitation_stores_only_hash_and_can_precede_human_user(self) -> None: + issued = issue_invitation( + organization=self.first_organization, + email=" INVITED@Example.Test ", + role=EmployeeRole.EMPLOYEE, + expires_at=timezone.now() + timedelta(hours=24), + created_by=self.first_membership, + ) + invitation = OrganizationInvitation.objects.get(pk=issued.invitation.pk) + self.assertEqual(invitation.email, "invited@example.test") + self.assertNotEqual(invitation.token_hash, issued.token) + self.assertNotIn(issued.token, invitation.token_hash) + self.assertFalse(HumanUser.objects.filter(email=invitation.email).exists()) + self.assertEqual(pending_invitation_for_token(issued.token), invitation) + self.assertFalse(any(field.name == "password" for field in invitation._meta.fields)) + + def test_invitation_creator_cannot_cross_organization(self) -> None: + with self.assertRaises(ValidationError): + OrganizationInvitation.objects.create( + organization=self.second_organization, + email="invited@example.test", + role=EmployeeRole.EMPLOYEE, + token_hash="a" * 64, + expires_at=timezone.now() + timedelta(hours=24), + created_by=self.first_membership, + ) diff --git a/apps/backend/hub_platform/identity/test_membership_migration.py b/apps/backend/hub_platform/identity/test_membership_migration.py new file mode 100644 index 0000000..7fe06f3 --- /dev/null +++ b/apps/backend/hub_platform/identity/test_membership_migration.py @@ -0,0 +1,87 @@ +from django.db import connection +from django.db.migrations.executor import MigrationExecutor +from django.test import TransactionTestCase + + +class MembershipMigrationTests(TransactionTestCase): + migrate_from = [("identity", "0011_enforce_capability_registry")] + migrate_to = [("identity", "0012_membership_identity")] + + def setUp(self) -> None: + super().setUp() + executor = MigrationExecutor(connection) + executor.migrate(self.migrate_from) + old_apps = executor.loader.project_state(self.migrate_from).apps + + Organization = old_apps.get_model("identity", "Organization") + HumanUser = old_apps.get_model("identity", "HumanUser") + EmployeeProfile = old_apps.get_model("identity", "EmployeeProfile") + AccessProfile = old_apps.get_model("identity", "AccessProfile") + EmployeeAccessAssignment = old_apps.get_model( + "identity", "EmployeeAccessAssignment" + ) + + organization = Organization.objects.create(name="Edevs", slug="edevs") + owner_user = HumanUser.objects.create(email="owner@example.test", password="hash") + employee_user = HumanUser.objects.create(email="employee@example.test", password="hash") + owner = EmployeeProfile.objects.create( + user=owner_user, + organization=organization, + role="OWNER", + position_title="Owner", + ) + employee = EmployeeProfile.objects.create( + user=employee_user, + organization=organization, + role="EMPLOYEE", + position_title="Specialist", + must_change_password=True, + totp_enabled=True, + totp_secret="JBSWY3DPEHPK3PXP", + ) + access_profile = AccessProfile.objects.create( + organization=organization, + name="Sales", + ) + assignment = EmployeeAccessAssignment.objects.create( + employee=employee, + access_profile=access_profile, + scope_type="ORGANIZATION", + assigned_by=owner, + ) + self.organization_id = organization.pk + self.employee_user_id = employee_user.pk + self.membership_id = employee.pk + self.assignment_id = assignment.pk + + def tearDown(self) -> None: + executor = MigrationExecutor(connection) + executor.migrate(executor.loader.graph.leaf_nodes()) + super().tearDown() + + def test_forward_migration_preserves_identity_primary_keys_and_access(self) -> None: + executor = MigrationExecutor(connection) + executor.migrate(self.migrate_to) + new_apps = executor.loader.project_state(self.migrate_to).apps + + Organization = new_apps.get_model("identity", "Organization") + HumanUser = new_apps.get_model("identity", "HumanUser") + OrganizationMembership = new_apps.get_model( + "identity", "OrganizationMembership" + ) + EmployeeAccessAssignment = new_apps.get_model( + "identity", "EmployeeAccessAssignment" + ) + + organization = Organization.objects.get(pk=self.organization_id) + user = HumanUser.objects.get(pk=self.employee_user_id) + membership = OrganizationMembership.objects.get(pk=self.membership_id) + assignment = EmployeeAccessAssignment.objects.get(pk=self.assignment_id) + + self.assertIsNotNone(organization.public_id) + self.assertEqual(membership.pk, self.membership_id) + self.assertEqual(membership.user_id, self.employee_user_id) + self.assertEqual(assignment.employee_id, self.membership_id) + self.assertTrue(user.must_change_password) + self.assertTrue(user.totp_enabled) + self.assertEqual(user.totp_secret, "JBSWY3DPEHPK3PXP") diff --git a/apps/backend/hub_platform/identity/tests.py b/apps/backend/hub_platform/identity/tests.py index b5abd96..2b93424 100644 --- a/apps/backend/hub_platform/identity/tests.py +++ b/apps/backend/hub_platform/identity/tests.py @@ -236,11 +236,10 @@ class AuthEndpointTests(TestCase): self.assertTrue(valid.json()["valid"]) self.assertFalse(invalid.json()["valid"]) - def test_change_temporary_password_clears_profile_flag(self) -> None: + def test_change_temporary_password_clears_user_flag(self) -> None: owner = HumanUser.objects.get(email="owner@edevs.tech") - profile = owner.employee_profile - profile.must_change_password = True - profile.save(update_fields=["must_change_password"]) + owner.must_change_password = True + owner.save(update_fields=["must_change_password"]) self.client.login(username="owner@edevs.tech", password="temporary-password") response = self.client.post( @@ -255,13 +254,13 @@ class AuthEndpointTests(TestCase): ) self.assertEqual(response.status_code, 200) - profile.refresh_from_db() - self.assertFalse(profile.must_change_password) + owner.refresh_from_db() + self.assertFalse(owner.must_change_password) def test_change_temporary_password_rejects_weak_password(self) -> None: owner = HumanUser.objects.get(email="owner@edevs.tech") - owner.employee_profile.must_change_password = True - owner.employee_profile.save(update_fields=["must_change_password"]) + owner.must_change_password = True + owner.save(update_fields=["must_change_password"]) self.client.login(username="owner@edevs.tech", password="temporary-password") response = self.client.post( @@ -271,8 +270,8 @@ class AuthEndpointTests(TestCase): ) self.assertEqual(response.status_code, 400) - owner.employee_profile.refresh_from_db() - self.assertTrue(owner.employee_profile.must_change_password) + owner.refresh_from_db() + self.assertTrue(owner.must_change_password) def test_profile_update_changes_current_user_identity(self) -> None: self.client.login(username="owner@edevs.tech", password="temporary-password") @@ -307,25 +306,27 @@ class AuthEndpointTests(TestCase): owner = HumanUser.objects.get(email="owner@edevs.tech") profile = owner.employee_profile profile.totp_required = False - profile.totp_enabled = False - profile.totp_secret = "JBSWY3DPEHPK3PXP" - profile.save(update_fields=["totp_required", "totp_enabled", "totp_secret"]) + profile.save(update_fields=["totp_required"]) + owner.totp_enabled = False + owner.totp_secret = "JBSWY3DPEHPK3PXP" + owner.save(update_fields=["totp_enabled", "totp_secret"]) self.client.login(username="owner@edevs.tech", password="temporary-password") response = self.client.post("/api/v1/auth/profile/totp/start/") self.assertEqual(response.status_code, 200) profile.refresh_from_db() + owner.refresh_from_db() self.assertTrue(profile.totp_required) - self.assertFalse(profile.totp_enabled) - self.assertEqual(profile.totp_secret, "") + self.assertFalse(owner.totp_enabled) + self.assertEqual(owner.totp_secret, "") def test_profile_totp_disable_requires_password_and_revokes_other_sessions(self) -> None: owner = HumanUser.objects.get(email="owner@edevs.tech") profile = owner.employee_profile - profile.totp_enabled = True - profile.totp_secret = "JBSWY3DPEHPK3PXP" - profile.save(update_fields=["totp_enabled", "totp_secret"]) + owner.totp_enabled = True + owner.totp_secret = "JBSWY3DPEHPK3PXP" + owner.save(update_fields=["totp_enabled", "totp_secret"]) self.client.login(username="owner@edevs.tech", password="temporary-password") other_session = SessionStore() other_session["_auth_user_id"] = str(owner.id) @@ -342,9 +343,10 @@ class AuthEndpointTests(TestCase): self.assertEqual(response.status_code, 200) self.assertEqual(response.json()["revoked"], 1) profile.refresh_from_db() + owner.refresh_from_db() self.assertFalse(profile.totp_required) - self.assertFalse(profile.totp_enabled) - self.assertEqual(profile.totp_secret, "") + self.assertFalse(owner.totp_enabled) + self.assertEqual(owner.totp_secret, "") def test_totp_setup_and_confirm_enables_profile_totp(self) -> None: # TOTP по умолчанию не требуется; включаем требование, чтобы пройти setup→confirm. @@ -369,14 +371,13 @@ class AuthEndpointTests(TestCase): self.assertEqual(confirm_response.status_code, 200) owner = HumanUser.objects.get(email="owner@edevs.tech") - self.assertTrue(owner.employee_profile.totp_enabled) + self.assertTrue(owner.totp_enabled) def test_enabled_totp_requires_second_factor_before_session(self) -> None: owner = HumanUser.objects.get(email="owner@edevs.tech") - profile = owner.employee_profile - profile.totp_secret = "JBSWY3DPEHPK3PXP" - profile.totp_enabled = True - profile.save(update_fields=["totp_secret", "totp_enabled"]) + owner.totp_secret = "JBSWY3DPEHPK3PXP" + owner.totp_enabled = True + owner.save(update_fields=["totp_secret", "totp_enabled"]) login_response = self.client.post( "/api/v1/auth/login/", @@ -390,7 +391,7 @@ class AuthEndpointTests(TestCase): verify_response = self.client.post( "/api/v1/auth/totp/verify/", - data=json.dumps({"code": _totp_code(profile.totp_secret)}), + data=json.dumps({"code": _totp_code(owner.totp_secret)}), content_type="application/json", ) @@ -418,7 +419,7 @@ class EmployeeEndpointTests(TestCase): self.client = APIClient() self.client.login(username="owner@edevs.tech", password="temporary-password") - def test_owner_creates_operator_with_temporary_password(self) -> None: + def test_owner_cannot_create_operator_with_temporary_password(self) -> None: response = self.client.post( "/api/v1/employees/operators/", data=json.dumps( @@ -432,12 +433,8 @@ class EmployeeEndpointTests(TestCase): content_type="application/json", ) - self.assertEqual(response.status_code, 201) - operator = HumanUser.objects.get(email="operator@edevs.tech") - self.assertTrue(operator.check_password("operator-password")) - self.assertEqual(operator.employee_profile.role, EmployeeRole.EMPLOYEE) - self.assertTrue(operator.employee_profile.must_change_password) - self.assertTrue(AuditEvent.objects.filter(action="identity.employee_created").exists()) + self.assertEqual(response.status_code, 400) + self.assertFalse(HumanUser.objects.filter(email="operator@edevs.tech").exists()) def test_operator_cannot_create_operator(self) -> None: operator = HumanUser.objects.create_user(email="operator@edevs.tech", password="operator-password") @@ -481,7 +478,7 @@ class EmployeeEndpointTests(TestCase): self.assertEqual(response.status_code, 200) operator.refresh_from_db() - self.assertFalse(operator.is_active) + self.assertTrue(operator.is_active) self.assertTrue(operator.employee_profile.is_blocked) self.assertTrue(AuditEvent.objects.filter(action="identity.employee_blocked").exists()) @@ -509,19 +506,18 @@ class EmployeeEndpointTests(TestCase): operator.employee_profile.refresh_from_db() self.assertEqual(operator.email, "anna.kotova@edevs.tech") self.assertEqual(operator.employee_profile.phone, "+7 916 245 14 03") - self.assertTrue(operator.employee_profile.totp_enabled) + self.assertFalse(operator.totp_enabled) - def test_owner_resets_operator_password(self) -> None: + def test_owner_cannot_reset_operator_global_password(self) -> None: operator = HumanUser.objects.get(email="a.kotova@edevs.tech") + password_hash = operator.password response = self.client.post(f"/api/v1/employees/{operator.id}/reset-password/") - self.assertEqual(response.status_code, 200) - payload = response.json() + self.assertEqual(response.status_code, 403) operator.refresh_from_db() - operator.employee_profile.refresh_from_db() - self.assertTrue(operator.check_password(payload["temporaryPassword"])) - self.assertTrue(operator.employee_profile.must_change_password) + self.assertEqual(operator.password, password_hash) + self.assertFalse(operator.must_change_password) def test_owner_unblocks_operator(self) -> None: operator = HumanUser.objects.get(email="a.kotova@edevs.tech") @@ -606,19 +602,18 @@ class TotpSecretEncryptionTests(TestCase): from hub_platform.identity.crypto import decrypt_secret owner = HumanUser.objects.get(email="owner@edevs.tech") - profile = owner.employee_profile - profile.totp_secret = "JBSWY3DPEHPK3PXP" - profile.save(update_fields=["totp_secret"]) + owner.totp_secret = "JBSWY3DPEHPK3PXP" + owner.save(update_fields=["totp_secret"]) with connection.cursor() as cursor: - cursor.execute("SELECT totp_secret FROM identity_employeeprofile WHERE user_id = %s", [owner.id]) + cursor.execute("SELECT totp_secret FROM identity_humanuser WHERE id = %s", [owner.id]) stored = cursor.fetchone()[0] self.assertNotEqual(stored, "JBSWY3DPEHPK3PXP") self.assertEqual(decrypt_secret(stored), "JBSWY3DPEHPK3PXP") - profile.refresh_from_db() - self.assertEqual(profile.totp_secret, "JBSWY3DPEHPK3PXP") + owner.refresh_from_db() + self.assertEqual(owner.totp_secret, "JBSWY3DPEHPK3PXP") @override_settings(CACHES=_LOCMEM_CACHE) @@ -727,7 +722,6 @@ class EmployeeModelInvariantTests(TestCase): { "email": "no-title@edevs.tech", "fullName": "No Title", - "temporaryPassword": "temporary-password", } ), content_type="application/json", @@ -769,7 +763,6 @@ class EmployeeGovernanceTests(TestCase): "email": email, "fullName": "New Member", "positionTitle": "Позиция", - "temporaryPassword": "temporary-password", "role": role, } ), @@ -816,7 +809,8 @@ class EmployeeGovernanceTests(TestCase): response = self._client("admin@edevs.tech").post(f"/api/v1/employees/{emp.id}/block/") self.assertEqual(response.status_code, 200) emp.refresh_from_db() - self.assertFalse(emp.is_active) + self.assertTrue(emp.is_active) + self.assertTrue(emp.employee_profile.is_blocked) def test_admin_cannot_block_another_admin(self) -> None: other = self._make("admin2@edevs.tech", EmployeeRole.ADMIN)