mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
✨ feat(identity): add organization memberships
This commit is contained in:
1 parent
dd849fa2d4
commit
a134cedfcd
29 files changed
+852
-234
No files matched your search
@@ -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
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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)})
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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})
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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)})
|
||||
|
||||
@@ -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)) + "!"
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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.")
|
||||
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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),
|
||||
|
||||
@@ -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": []}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user