406 lines
17 KiB
Python
406 lines
17 KiB
Python
from __future__ import annotations
|
||
|
||
from dataclasses import asdict
|
||
from typing import Dict, List, Optional, Tuple
|
||
|
||
from ad_user_creator.exceptions import InputValidationError, LdapOperationError
|
||
from ad_user_creator.ldap_client import LdapClient
|
||
from ad_user_creator.models import AppConfig, ResolvedUserPlan, UserInputRecord, UserProcessResult
|
||
from ad_user_creator.persistence import StateStore
|
||
|
||
|
||
def build_department_dn_fragment(dept_ou: str) -> str:
|
||
parts = [part.strip() for part in dept_ou.split("/") if part.strip()]
|
||
if not parts:
|
||
raise InputValidationError("部门 OU 不能为空")
|
||
return ",".join(f"OU={part}" for part in reversed(parts))
|
||
|
||
|
||
def build_user_dn(display_name: str, dept_ou: str, people_base_dn: str) -> str:
|
||
dept_fragment = build_department_dn_fragment(dept_ou)
|
||
return f"CN={display_name},{dept_fragment},{people_base_dn}"
|
||
|
||
|
||
class UserService:
|
||
def __init__(self, config: AppConfig, state_store: StateStore, ldap_client: LdapClient) -> None:
|
||
self.config = config
|
||
self.state_store = state_store
|
||
self.ldap_client = ldap_client
|
||
self.group_gid_map = dict(config.groups_gid_map)
|
||
self.discovered_group_gid_map: Dict[str, int] = {}
|
||
|
||
def get_discovered_group_gid_map(self) -> Dict[str, int]:
|
||
return dict(self.discovered_group_gid_map)
|
||
|
||
def preview_plan(self, record: UserInputRecord) -> ResolvedUserPlan:
|
||
gid_number = self._resolve_base_gid(record.base_group, allow_ad_gid_lookup=False)
|
||
next_uid_number = self.state_store.get_next_uid_number()
|
||
return self._resolve_plan(record, next_uid_number, gid_number, optional_missing_groups=[])
|
||
|
||
def process_user(self, record: UserInputRecord, dry_run: bool = False) -> UserProcessResult:
|
||
uid_value = record.sam_account_name
|
||
allow_ad_gid_lookup = not dry_run
|
||
project_gid_map = self._build_group_gid_map(record.project_groups, allow_ad_gid_lookup)
|
||
resource_gid_map = self._build_group_gid_map(record.resource_groups, allow_ad_gid_lookup)
|
||
try:
|
||
gid_number = self._resolve_base_gid(record.base_group, allow_ad_gid_lookup)
|
||
except InputValidationError as exc:
|
||
return UserProcessResult(
|
||
status="FAILED",
|
||
reason=str(exc),
|
||
uid=uid_value,
|
||
base_gid=None,
|
||
project_group_gid_map=project_gid_map,
|
||
resource_group_gid_map=resource_gid_map,
|
||
raw=asdict(record),
|
||
)
|
||
|
||
optional_missing_groups: List[str] = []
|
||
if not dry_run:
|
||
base_ok, missing_optional = self._validate_groups(record)
|
||
optional_missing_groups = missing_optional
|
||
if not base_ok:
|
||
return UserProcessResult(
|
||
status="FAILED",
|
||
reason=f"基础组不存在: {record.base_group}",
|
||
uid=uid_value,
|
||
base_gid=gid_number,
|
||
project_group_gid_map=project_gid_map,
|
||
resource_group_gid_map=resource_gid_map,
|
||
raw=asdict(record),
|
||
)
|
||
if missing_optional and not self.config.behavior.skip_missing_optional_groups:
|
||
return UserProcessResult(
|
||
status="FAILED",
|
||
reason=f"可选组不存在且配置不允许跳过: {','.join(missing_optional)}",
|
||
uid=uid_value,
|
||
base_gid=gid_number,
|
||
project_group_gid_map=project_gid_map,
|
||
resource_group_gid_map=resource_gid_map,
|
||
raw=asdict(record),
|
||
)
|
||
|
||
if not dry_run:
|
||
existing_user_dn = self.ldap_client.find_user_dn_by_sam(record.sam_account_name)
|
||
if existing_user_dn:
|
||
return self._process_existing_user(
|
||
record=record,
|
||
existing_user_dn=existing_user_dn,
|
||
gid_number=gid_number,
|
||
optional_missing_groups=optional_missing_groups,
|
||
uid_value=uid_value,
|
||
project_gid_map=project_gid_map,
|
||
resource_gid_map=resource_gid_map,
|
||
)
|
||
|
||
uid_number = self.state_store.get_next_uid_number() if dry_run else self.state_store.commit_next_uid_number()
|
||
plan = self._resolve_plan(record, uid_number, gid_number, optional_missing_groups=optional_missing_groups)
|
||
|
||
if dry_run:
|
||
reason = "dry-run 未写入 LDAP(将执行: 创建用户->设置初始密码->启用用户->加组)"
|
||
if optional_missing_groups:
|
||
reason += f";可选组缺失: {','.join(optional_missing_groups)}"
|
||
return UserProcessResult(
|
||
status="CREATED",
|
||
reason=reason,
|
||
user_dn=plan.user_dn,
|
||
uid_number=plan.uid_number,
|
||
uid=uid_value,
|
||
base_gid=gid_number,
|
||
project_group_gid_map=project_gid_map,
|
||
resource_group_gid_map=resource_gid_map,
|
||
linux_uid_number=plan.uid_number,
|
||
raw=asdict(record),
|
||
)
|
||
|
||
attrs = self._build_ldap_attributes(plan)
|
||
try:
|
||
self.ldap_client.create_user(plan.user_dn, attrs)
|
||
except LdapOperationError as exc:
|
||
return UserProcessResult(
|
||
status="FAILED",
|
||
reason=f"create-user-failed: {exc}",
|
||
user_dn=plan.user_dn,
|
||
uid=uid_value,
|
||
base_gid=gid_number,
|
||
project_group_gid_map=project_gid_map,
|
||
resource_group_gid_map=resource_gid_map,
|
||
linux_uid_number=plan.uid_number,
|
||
raw=asdict(record),
|
||
)
|
||
|
||
try:
|
||
self.ldap_client.set_user_password(plan.user_dn, self.config.defaults.initial_password)
|
||
except LdapOperationError as exc:
|
||
return UserProcessResult(
|
||
status="FAILED",
|
||
reason=f"password-set-failed: {exc}",
|
||
user_dn=plan.user_dn,
|
||
uid=uid_value,
|
||
base_gid=gid_number,
|
||
project_group_gid_map=project_gid_map,
|
||
resource_group_gid_map=resource_gid_map,
|
||
linux_uid_number=plan.uid_number,
|
||
raw=asdict(record),
|
||
)
|
||
|
||
try:
|
||
self.ldap_client.set_user_enabled(plan.user_dn, enabled=True)
|
||
except LdapOperationError as exc:
|
||
return UserProcessResult(
|
||
status="FAILED",
|
||
reason=f"enable-user-failed: {exc}",
|
||
user_dn=plan.user_dn,
|
||
uid=uid_value,
|
||
base_gid=gid_number,
|
||
project_group_gid_map=project_gid_map,
|
||
resource_group_gid_map=resource_gid_map,
|
||
linux_uid_number=plan.uid_number,
|
||
raw=asdict(record),
|
||
)
|
||
|
||
try:
|
||
added_groups = self._ensure_groups(
|
||
plan.user_dn,
|
||
plan.base_group,
|
||
plan.project_groups,
|
||
plan.resource_groups,
|
||
plan.optional_missing_groups,
|
||
)
|
||
except LdapOperationError as exc:
|
||
return UserProcessResult(
|
||
status="FAILED",
|
||
reason=f"add-group-failed: {exc}",
|
||
user_dn=plan.user_dn,
|
||
uid=uid_value,
|
||
base_gid=gid_number,
|
||
project_group_gid_map=project_gid_map,
|
||
resource_group_gid_map=resource_gid_map,
|
||
raw=asdict(record),
|
||
)
|
||
|
||
reason = "创建成功"
|
||
if optional_missing_groups:
|
||
reason += f";可选组已跳过: {','.join(optional_missing_groups)}"
|
||
if added_groups:
|
||
reason += f";新增组成员: {','.join(added_groups)}"
|
||
return UserProcessResult(
|
||
status="CREATED",
|
||
reason=reason,
|
||
user_dn=plan.user_dn,
|
||
uid_number=plan.uid_number,
|
||
uid=uid_value,
|
||
base_gid=gid_number,
|
||
project_group_gid_map=project_gid_map,
|
||
resource_group_gid_map=resource_gid_map,
|
||
linux_uid_number=plan.uid_number,
|
||
raw=asdict(record),
|
||
)
|
||
|
||
def _resolve_group_gid(self, group_name: str, allow_ad_gid_lookup: bool) -> Tuple[Optional[int], str]:
|
||
if allow_ad_gid_lookup:
|
||
gid_from_ad = self.ldap_client.get_group_gid_number(group_name)
|
||
if gid_from_ad is not None:
|
||
self.group_gid_map[group_name] = gid_from_ad
|
||
self.discovered_group_gid_map[group_name] = gid_from_ad
|
||
return gid_from_ad, "ad"
|
||
gid_from_config = self.group_gid_map.get(group_name)
|
||
if gid_from_config is not None:
|
||
return int(gid_from_config), "config"
|
||
return None, "none"
|
||
|
||
def _resolve_base_gid(self, base_group: str, allow_ad_gid_lookup: bool) -> int:
|
||
gid, _ = self._resolve_group_gid(base_group, allow_ad_gid_lookup)
|
||
if gid is None:
|
||
raise InputValidationError(f"基础组未配置 gidNumber: {base_group}")
|
||
return gid
|
||
|
||
def _resolve_plan(
|
||
self,
|
||
record: UserInputRecord,
|
||
uid_number: int,
|
||
gid_number: int,
|
||
optional_missing_groups: List[str],
|
||
) -> ResolvedUserPlan:
|
||
user_dn = build_user_dn(record.display_name, record.dept_ou, self.config.ldap.people_base_dn)
|
||
return ResolvedUserPlan(
|
||
user_dn=user_dn,
|
||
display_name=record.display_name,
|
||
sam_account_name=record.sam_account_name,
|
||
email=record.email,
|
||
uid=record.sam_account_name,
|
||
uid_number=uid_number,
|
||
gid_number=gid_number,
|
||
unix_home_directory=f"/home/{record.sam_account_name}",
|
||
base_group=record.base_group,
|
||
project_groups=record.project_groups,
|
||
resource_groups=record.resource_groups,
|
||
optional_missing_groups=optional_missing_groups,
|
||
)
|
||
|
||
def _validate_groups(self, record: UserInputRecord) -> Tuple[bool, List[str]]:
|
||
if not self.ldap_client.group_exists(record.base_group):
|
||
return False, []
|
||
optional_missing: List[str] = []
|
||
for group in record.project_groups + record.resource_groups:
|
||
if not self.ldap_client.group_exists(group):
|
||
optional_missing.append(group)
|
||
return True, optional_missing
|
||
|
||
def _build_ldap_attributes(self, plan: ResolvedUserPlan) -> Dict[str, object]:
|
||
attrs: Dict[str, object] = {
|
||
"cn": plan.display_name,
|
||
"displayName": plan.display_name,
|
||
"sAMAccountName": plan.sam_account_name,
|
||
"mail": plan.email,
|
||
"uid": plan.uid,
|
||
"uidNumber": str(plan.uid_number),
|
||
"gidNumber": str(plan.gid_number),
|
||
"unixHomeDirectory": plan.unix_home_directory,
|
||
"userAccountControl": "514",
|
||
}
|
||
if self.config.ldap.upn_suffix:
|
||
attrs["userPrincipalName"] = f"{plan.sam_account_name}@{self.config.ldap.upn_suffix}"
|
||
return attrs
|
||
|
||
def _build_desired_update_attrs(self, record: UserInputRecord, gid_number: int) -> Dict[str, str]:
|
||
desired = {
|
||
"displayName": record.display_name,
|
||
"mail": record.email,
|
||
"uid": record.sam_account_name,
|
||
"unixHomeDirectory": f"/home/{record.sam_account_name}",
|
||
"gidNumber": str(gid_number),
|
||
}
|
||
if self.config.ldap.upn_suffix:
|
||
desired["userPrincipalName"] = f"{record.sam_account_name}@{self.config.ldap.upn_suffix}"
|
||
return desired
|
||
|
||
def _build_group_gid_map(self, groups: List[str], allow_ad_gid_lookup: bool) -> Dict[str, str]:
|
||
group_gid_map: Dict[str, str] = {}
|
||
for group in groups:
|
||
gid, _ = self._resolve_group_gid(group, allow_ad_gid_lookup)
|
||
group_gid_map[group] = "NA" if gid is None else str(gid)
|
||
return group_gid_map
|
||
|
||
def _calculate_attr_changes(self, current: Dict[str, str], desired: Dict[str, str]) -> Dict[str, str]:
|
||
changes: Dict[str, str] = {}
|
||
for key, desired_value in desired.items():
|
||
current_value = str(current.get(key, "") or "").strip()
|
||
if current_value != str(desired_value).strip():
|
||
changes[key] = desired_value
|
||
return changes
|
||
|
||
def _ensure_groups(
|
||
self,
|
||
user_dn: str,
|
||
base_group: str,
|
||
project_groups: List[str],
|
||
resource_groups: List[str],
|
||
optional_missing_groups: List[str],
|
||
) -> List[str]:
|
||
added_groups: List[str] = []
|
||
base_group_dn = self.ldap_client.get_group_dn(base_group)
|
||
if self.ldap_client.add_user_to_group_if_missing(user_dn, base_group_dn):
|
||
added_groups.append(base_group)
|
||
|
||
optional_groups = list(project_groups) + list(resource_groups)
|
||
skip_set = set(optional_missing_groups)
|
||
for group in optional_groups:
|
||
if group in skip_set:
|
||
continue
|
||
group_dn = self.ldap_client.get_group_dn(group)
|
||
if self.ldap_client.add_user_to_group_if_missing(user_dn, group_dn):
|
||
added_groups.append(group)
|
||
return added_groups
|
||
|
||
def _process_existing_user(
|
||
self,
|
||
record: UserInputRecord,
|
||
existing_user_dn: str,
|
||
gid_number: int,
|
||
optional_missing_groups: List[str],
|
||
uid_value: str,
|
||
project_gid_map: Dict[str, str],
|
||
resource_gid_map: Dict[str, str],
|
||
) -> UserProcessResult:
|
||
attrs_to_read = ["displayName", "mail", "uid", "uidNumber", "unixHomeDirectory", "gidNumber"]
|
||
if self.config.ldap.upn_suffix:
|
||
attrs_to_read.append("userPrincipalName")
|
||
|
||
try:
|
||
current_attrs = self.ldap_client.get_user_attributes(existing_user_dn, attrs_to_read)
|
||
desired_attrs = self._build_desired_update_attrs(record, gid_number)
|
||
changes = self._calculate_attr_changes(current_attrs, desired_attrs)
|
||
if changes:
|
||
self.ldap_client.modify_user_attributes(existing_user_dn, changes)
|
||
|
||
added_groups = self._ensure_groups(
|
||
user_dn=existing_user_dn,
|
||
base_group=record.base_group,
|
||
project_groups=record.project_groups,
|
||
resource_groups=record.resource_groups,
|
||
optional_missing_groups=optional_missing_groups,
|
||
)
|
||
except LdapOperationError as exc:
|
||
return UserProcessResult(
|
||
status="FAILED",
|
||
reason=f"update-user-failed: {exc}",
|
||
user_dn=existing_user_dn,
|
||
uid=uid_value,
|
||
base_gid=gid_number,
|
||
project_group_gid_map=project_gid_map,
|
||
resource_group_gid_map=resource_gid_map,
|
||
linux_uid_number=None,
|
||
raw=asdict(record),
|
||
)
|
||
linux_uid_number: Optional[int] = None
|
||
raw_uid_number = str(current_attrs.get("uidNumber", "") or "").strip()
|
||
if raw_uid_number.isdigit():
|
||
linux_uid_number = int(raw_uid_number)
|
||
|
||
if changes:
|
||
reason = f"已更新字段: {','.join(changes.keys())}"
|
||
if added_groups:
|
||
reason += f";新增组成员: {','.join(added_groups)}"
|
||
if optional_missing_groups:
|
||
reason += f";可选组已跳过: {','.join(optional_missing_groups)}"
|
||
return UserProcessResult(
|
||
status="UPDATED",
|
||
reason=reason,
|
||
user_dn=existing_user_dn,
|
||
uid=uid_value,
|
||
base_gid=gid_number,
|
||
project_group_gid_map=project_gid_map,
|
||
resource_group_gid_map=resource_gid_map,
|
||
linux_uid_number=linux_uid_number,
|
||
raw=asdict(record),
|
||
)
|
||
|
||
reason = "用户已存在且字段无变化"
|
||
if added_groups:
|
||
reason += f";新增组成员: {','.join(added_groups)}"
|
||
return UserProcessResult(
|
||
status="UPDATED",
|
||
reason=reason,
|
||
user_dn=existing_user_dn,
|
||
uid=uid_value,
|
||
base_gid=gid_number,
|
||
project_group_gid_map=project_gid_map,
|
||
resource_group_gid_map=resource_gid_map,
|
||
linux_uid_number=linux_uid_number,
|
||
raw=asdict(record),
|
||
)
|
||
if optional_missing_groups:
|
||
reason += f";可选组已跳过: {','.join(optional_missing_groups)}"
|
||
return UserProcessResult(
|
||
status="SKIPPED_NO_CHANGE",
|
||
reason=reason,
|
||
user_dn=existing_user_dn,
|
||
uid=uid_value,
|
||
base_gid=gid_number,
|
||
project_group_gid_map=project_gid_map,
|
||
resource_group_gid_map=resource_gid_map,
|
||
linux_uid_number=linux_uid_number,
|
||
raw=asdict(record),
|
||
)
|