"""企业身份声明、目录账号和组织映射同步服务。""" import re from collections.abc import Mapping from typing import Any from django.contrib.auth import get_user_model from django.db import IntegrityError, transaction from django.utils import timezone from apps.accounts.models import ( EnterpriseDirectorySyncRun, EnterpriseIdentityProvider, ExternalIdentity, ) DEFAULT_CLAIM_MAPPINGS = { "subject": "sub", "username": "preferred_username", "email": "email", "display_name": "name", "groups": "groups", } class IdentitySyncError(ValueError): pass def resolved_claim_mappings(provider: EnterpriseIdentityProvider) -> dict[str, str]: return {**DEFAULT_CLAIM_MAPPINGS, **(provider.claim_mappings or {})} def read_claim(claims: Mapping[str, Any], path: str, default: Any = "") -> Any: value: Any = claims for part in path.split("."): if not isinstance(value, Mapping) or part not in value: return default value = value[part] return value def _clean_username(value: str) -> str: cleaned = re.sub(r"[^@\w.+-]", "_", value.strip(), flags=re.UNICODE).strip("._") return cleaned[:150] def _unique_username(provider: EnterpriseIdentityProvider, preferred: str, subject: str) -> str: user_model = get_user_model() base = ( _clean_username(preferred) or _clean_username(f"{provider.slug}_{subject}") or provider.slug ) if not user_model.objects.filter(username=base).exists(): return base for suffix in range(2, 10000): candidate = f"{base[: 150 - len(str(suffix)) - 1]}-{suffix}" if not user_model.objects.filter(username=candidate).exists(): return candidate raise IdentitySyncError("无法生成唯一的本地账号") def _claim_list(value: Any) -> list[str]: if isinstance(value, str): return [item.strip() for item in value.split(",") if item.strip()] if isinstance(value, (list, tuple, set)): return [str(item).strip() for item in value if str(item).strip()] return [] def _sync_business_groups( provider: EnterpriseIdentityProvider, identity: ExternalIdentity, external_groups: list[str], ) -> None: mappings = list(provider.group_mappings.select_related("user_group")) managed_group_ids = {mapping.user_group_id for mapping in mappings} desired_group_ids = { mapping.user_group_id for mapping in mappings if mapping.external_group in set(external_groups) and mapping.user_group.is_active } if managed_group_ids: identity.user.business_groups.remove(*(managed_group_ids - desired_group_ids)) if desired_group_ids: identity.user.business_groups.add(*desired_group_ids) @transaction.atomic def sync_identity_claims( provider: EnterpriseIdentityProvider, claims: Mapping[str, Any], *, login_event: bool = False, active: bool | None = None, claim_mappings: Mapping[str, str] | None = None, ) -> tuple[ExternalIdentity, bool]: """按稳定 subject 同步本地账号、档案和受管业务组,不依据显示名关联身份。""" mappings = dict(claim_mappings or resolved_claim_mappings(provider)) subject = str(read_claim(claims, mappings["subject"], "")).strip() if not subject: raise IdentitySyncError("身份声明缺少稳定 subject") identity = ( ExternalIdentity.objects.select_for_update() .select_related("user") .filter(provider=provider, subject=subject) .first() ) created = identity is None email = str(read_claim(claims, mappings["email"], "") or "").strip().lower() preferred_username = str(read_claim(claims, mappings["username"], "") or "").strip() display_name = str(read_claim(claims, mappings["display_name"], "") or "").strip() if identity is None: if not provider.auto_create_users: raise IdentitySyncError("该身份提供方不允许自动创建账号") user_model = get_user_model() user = None if email and provider.configuration.get("allow_email_linking") is True: user = user_model.objects.filter(email__iexact=email).order_by("id").first() if user is None: username = _unique_username(provider, preferred_username or email, subject) user = user_model(username=username, email=email, first_name=display_name) user.set_unusable_password() user.save() identity = ExternalIdentity.objects.create( provider=provider, subject=subject, user=user, ) user = identity.user changed_fields: list[str] = [] if provider.sync_profile: if email and user.email != email: user.email = email changed_fields.append("email") if display_name and user.first_name != display_name: user.first_name = display_name changed_fields.append("first_name") if active is not None and user.is_active != active: user.is_active = active changed_fields.append("is_active") if changed_fields: user.save(update_fields=changed_fields) if login_event and not user.is_active: raise IdentitySyncError("账号已停用") now = timezone.now() identity.claims_snapshot = dict(claims) identity.last_synced_at = now update_fields = ["claims_snapshot", "last_synced_at", "updated_at"] if login_event: identity.last_login_at = now update_fields.append("last_login_at") identity.save(update_fields=update_fields) if provider.sync_groups: _sync_business_groups( provider, identity, _claim_list(read_claim(claims, mappings["groups"], [])), ) return identity, created def run_directory_sync( provider: EnterpriseIdentityProvider, records: list[dict[str, Any]], initiated_by, ) -> EnterpriseDirectorySyncRun: """逐条同步目录快照,隔离单条失败并保留最多一百条脱敏错误摘要。""" run = EnterpriseDirectorySyncRun.objects.create( provider=provider, initiated_by=initiated_by, total_count=len(records), ) errors: list[dict[str, str]] = [] for index, record in enumerate(records): claims = { "sub": record.get("subject", ""), "preferred_username": record.get("username", ""), "email": record.get("email", ""), "name": record.get("display_name", ""), "groups": record.get("groups", []), } try: _, created = sync_identity_claims( provider, claims, active=bool(record.get("active", True)), claim_mappings=DEFAULT_CLAIM_MAPPINGS, ) if created: run.created_count += 1 else: run.updated_count += 1 except (IdentitySyncError, IntegrityError) as error: run.failed_count += 1 errors.append( { "index": str(index), "subject": str(record.get("subject", ""))[:255], "error": str(error), } ) run.errors = errors[:100] if run.failed_count == 0: run.status = EnterpriseDirectorySyncRun.Status.COMPLETED elif run.failed_count == run.total_count: run.status = EnterpriseDirectorySyncRun.Status.FAILED else: run.status = EnterpriseDirectorySyncRun.Status.PARTIAL run.finished_at = timezone.now() run.save( update_fields=[ "created_count", "updated_count", "failed_count", "errors", "status", "finished_at", ] ) return run