Files
ai-ticket-platform/backend/apps/accounts/identity_services.py

231 lines
7.7 KiB
Python

"""企业身份声明、目录账号和组织映射同步服务。"""
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