✨ feat(identity): add organization memberships

This commit is contained in:
Andrey committed 2026-07-14 23:59:06 +03:00
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)})
+29 -4
View File
@@ -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.")
@@ -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",
),
),
]
+56 -11
View File
@@ -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),
+29 -11
View File
@@ -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")
+44 -50
View File
@@ -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)