231 lines
7.7 KiB
Python
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
|