feat: add enterprise identity and channel integrations

This commit is contained in:
hailin
2026-07-27 19:03:42 +08:00
parent d93cb75c10
commit e69368a2f3
44 changed files with 5045 additions and 69 deletions

View File

@@ -0,0 +1,381 @@
from collections.abc import Mapping
from typing import Any
from urllib.parse import urlparse
from django.conf import settings
from django.contrib.auth import login
from django.core.exceptions import ValidationError
from django.db import IntegrityError, transaction
from django.db.models import Count
from django.http import HttpResponseRedirect
from ninja import Router
from ninja.errors import HttpError
from apps.accounts.api import require_permission
from apps.accounts.identity_schemas import (
DirectorySyncIn,
DirectorySyncRunOut,
EnterpriseGroupMappingIn,
EnterpriseGroupMappingOut,
IdentityProviderCreateIn,
IdentityProviderOut,
IdentityProviderUpdateIn,
PublicIdentityProviderOut,
)
from apps.accounts.identity_services import (
IdentitySyncError,
run_directory_sync,
sync_identity_claims,
)
from apps.accounts.models import (
EnterpriseGroupMapping,
EnterpriseIdentityProvider,
UserGroup,
)
from apps.accounts.oidc import (
OIDCConfigurationError,
build_oidc_client,
callback_uri,
frontend_redirect,
normalize_return_to,
)
from apps.audit.models import OperationAuditLog
from apps.modelhub.crypto import (
CredentialEncryptionError,
encrypt_secret,
mask_secret,
)
router = Router(tags=["enterprise-identity"])
SENSITIVE_CONFIG_FRAGMENTS = ("password", "secret", "token", "credential")
def _audit(user, action: str, target_type: str, target_id: str, snapshot: dict, result="success"):
OperationAuditLog.objects.create(
user=user if getattr(user, "is_authenticated", False) else None,
module="identity",
action=action,
target_type=target_type,
target_id=target_id,
request_snapshot=snapshot,
result=result,
)
def _validate_configuration(configuration: Mapping[str, Any]) -> None:
def visit(value: Any) -> None:
if isinstance(value, Mapping):
for key, nested in value.items():
lowered = str(key).lower()
if any(fragment in lowered for fragment in SENSITIVE_CONFIG_FRAGMENTS):
raise HttpError(400, f"敏感配置 {key} 必须通过 credential 字段加密保存")
visit(nested)
elif isinstance(value, list):
for nested in value:
visit(nested)
visit(configuration)
def _validate_provider(provider: EnterpriseIdentityProvider) -> None:
if provider.provider_type == EnterpriseIdentityProvider.ProviderType.OIDC:
if not provider.metadata_url and not provider.issuer:
raise HttpError(400, "OIDC 提供方必须配置 metadata_url 或 issuer")
if not provider.client_id:
raise HttpError(400, "OIDC 提供方必须配置 client_id")
if "openid" not in (provider.scopes or []):
raise HttpError(400, "OIDC scopes 必须包含 openid")
if provider.enabled and not provider.credential_encrypted:
raise HttpError(400, "启用 OIDC 前必须配置 client secret")
for url in (provider.issuer, provider.metadata_url):
if not url:
continue
parsed = urlparse(url)
local_development = settings.DEBUG and parsed.hostname in {"localhost", "127.0.0.1"}
if parsed.scheme != "https" and not local_development:
raise HttpError(400, "OIDC 地址必须使用 HTTPS")
elif provider.provider_type == EnterpriseIdentityProvider.ProviderType.LDAP:
if provider.enabled and not provider.configuration.get("server_uri"):
raise HttpError(400, "启用 LDAP 前必须配置 server_uri")
else:
raise HttpError(400, "不支持的身份提供方类型")
def _serialize_provider(provider: EnterpriseIdentityProvider) -> dict:
return {
"id": provider.id,
"slug": provider.slug,
"name": provider.name,
"provider_type": provider.provider_type,
"enabled": provider.enabled,
"button_label": provider.button_label or f"使用 {provider.name} 登录",
"issuer": provider.issuer,
"metadata_url": provider.metadata_url,
"client_id": provider.client_id,
"credential_set": bool(provider.credential_encrypted),
"credential_masked": mask_secret(provider.credential_encrypted),
"scopes": provider.scopes,
"claim_mappings": provider.claim_mappings,
"configuration": provider.configuration,
"auto_create_users": provider.auto_create_users,
"sync_profile": provider.sync_profile,
"sync_groups": provider.sync_groups,
"identity_count": getattr(provider, "identity_count", 0),
"group_mapping_count": getattr(provider, "group_mapping_count", 0),
"created_at": provider.created_at,
"updated_at": provider.updated_at,
}
def _serialize_mapping(mapping: EnterpriseGroupMapping) -> dict:
return {
"id": mapping.id,
"provider_id": mapping.provider_id,
"external_group": mapping.external_group,
"user_group_id": mapping.user_group_id,
"user_group_name": mapping.user_group.name,
"user_group_code": mapping.user_group.code,
"created_at": mapping.created_at,
}
@router.get("/auth/providers", response=list[PublicIdentityProviderOut], auth=None)
def public_providers(request):
return [
{
"slug": provider.slug,
"name": provider.name,
"button_label": provider.button_label or f"使用 {provider.name} 登录",
"provider_type": provider.provider_type,
}
for provider in EnterpriseIdentityProvider.objects.filter(
enabled=True,
provider_type=EnterpriseIdentityProvider.ProviderType.OIDC,
)
]
@router.get("/auth/oidc/{slug}/start", auth=None)
def oidc_start(request, slug: str, return_to: str = "/"):
provider = EnterpriseIdentityProvider.objects.filter(
slug=slug,
enabled=True,
provider_type=EnterpriseIdentityProvider.ProviderType.OIDC,
).first()
if provider is None:
raise HttpError(404, "身份提供方不存在或未启用")
try:
client = build_oidc_client(provider)
request.session[f"identity_return_to:{provider.slug}"] = normalize_return_to(return_to)
return client.authorize_redirect(request, callback_uri(request, provider))
except (OIDCConfigurationError, CredentialEncryptionError) as error:
_audit(None, "oidc_start_failed", "identity_provider", str(provider.id), {}, "failed")
raise HttpError(503, str(error)) from error
@router.get("/auth/oidc/{slug}/callback", auth=None)
def oidc_callback(request, slug: str):
provider = EnterpriseIdentityProvider.objects.filter(
slug=slug,
enabled=True,
provider_type=EnterpriseIdentityProvider.ProviderType.OIDC,
).first()
if provider is None:
return HttpResponseRedirect(frontend_redirect(error="身份提供方不存在或未启用"))
try:
client = build_oidc_client(provider)
token = client.authorize_access_token(request)
claims = token.get("userinfo")
if not isinstance(claims, Mapping):
raise IdentitySyncError("OIDC 响应缺少已校验的用户身份声明")
identity, created = sync_identity_claims(provider, claims, login_event=True)
login(request, identity.user)
return_to = request.session.pop(f"identity_return_to:{provider.slug}", "/")
_audit(
identity.user,
"oidc_login",
"external_identity",
str(identity.id),
{"provider": provider.slug, "identity_created": created},
)
return HttpResponseRedirect(frontend_redirect(return_to))
except Exception as error:
_audit(
None,
"oidc_callback_failed",
"identity_provider",
str(provider.id),
{"error_type": type(error).__name__},
"failed",
)
return HttpResponseRedirect(frontend_redirect(error="企业登录失败,请联系管理员"))
@router.get("/providers", response=list[IdentityProviderOut])
def list_providers(request):
require_permission(request, "system.setting.manage")
queryset = EnterpriseIdentityProvider.objects.annotate(
identity_count=Count("external_identities", distinct=True),
group_mapping_count=Count("group_mappings", distinct=True),
)
return [_serialize_provider(provider) for provider in queryset]
@router.post("/providers", response=IdentityProviderOut)
def create_provider(request, payload: IdentityProviderCreateIn):
require_permission(request, "system.setting.manage")
values = payload.dict()
credential = values.pop("credential", "")
_validate_configuration(values["configuration"])
try:
with transaction.atomic():
provider = EnterpriseIdentityProvider(
**values,
credential_encrypted=encrypt_secret(credential) if credential else "",
created_by=request.user,
updated_by=request.user,
)
_validate_provider(provider)
provider.full_clean()
provider.save()
except CredentialEncryptionError as error:
raise HttpError(400, str(error)) from error
except ValidationError as error:
raise HttpError(400, "; ".join(error.messages)) from error
except IntegrityError as error:
raise HttpError(400, "身份提供方标识已存在") from error
_audit(
request.user,
"create_provider",
"identity_provider",
str(provider.id),
{"slug": provider.slug, "provider_type": provider.provider_type},
)
provider.identity_count = 0
provider.group_mapping_count = 0
return _serialize_provider(provider)
@router.patch("/providers/{provider_id}", response=IdentityProviderOut)
def update_provider(request, provider_id: int, payload: IdentityProviderUpdateIn):
require_permission(request, "system.setting.manage")
provider = EnterpriseIdentityProvider.objects.get(id=provider_id)
values = payload.dict(exclude_unset=True)
credential = values.pop("credential", None)
if "configuration" in values:
_validate_configuration(values["configuration"])
try:
if credential:
provider.credential_encrypted = encrypt_secret(credential)
for field, value in values.items():
setattr(provider, field, value)
provider.updated_by = request.user
_validate_provider(provider)
provider.full_clean()
provider.save()
except CredentialEncryptionError as error:
raise HttpError(400, str(error)) from error
except ValidationError as error:
raise HttpError(400, "; ".join(error.messages)) from error
_audit(
request.user,
"update_provider",
"identity_provider",
str(provider.id),
{"fields": sorted(values), "credential_replaced": bool(credential)},
)
provider.identity_count = provider.external_identities.count()
provider.group_mapping_count = provider.group_mappings.count()
return _serialize_provider(provider)
@router.get(
"/providers/{provider_id}/group-mappings",
response=list[EnterpriseGroupMappingOut],
)
def list_group_mappings(request, provider_id: int):
require_permission(request, "system.setting.manage")
mappings = EnterpriseGroupMapping.objects.select_related("user_group").filter(
provider_id=provider_id
)
return [_serialize_mapping(mapping) for mapping in mappings]
@router.post(
"/providers/{provider_id}/group-mappings",
response=EnterpriseGroupMappingOut,
)
def create_group_mapping(
request,
provider_id: int,
payload: EnterpriseGroupMappingIn,
):
require_permission(request, "system.setting.manage")
provider = EnterpriseIdentityProvider.objects.get(id=provider_id)
user_group = UserGroup.objects.get(id=payload.user_group_id)
external_group = payload.external_group.strip()
if not external_group:
raise HttpError(400, "企业组织标识不能为空")
try:
mapping = EnterpriseGroupMapping.objects.create(
provider=provider,
external_group=external_group,
user_group=user_group,
)
except IntegrityError as error:
raise HttpError(400, "该外部组织映射已存在") from error
_audit(
request.user,
"create_group_mapping",
"identity_provider",
str(provider.id),
{"external_group": mapping.external_group, "user_group_id": user_group.id},
)
return _serialize_mapping(mapping)
@router.delete("/group-mappings/{mapping_id}")
def delete_group_mapping(request, mapping_id: int):
require_permission(request, "system.setting.manage")
mapping = EnterpriseGroupMapping.objects.select_related("provider").get(id=mapping_id)
snapshot = {
"external_group": mapping.external_group,
"user_group_id": mapping.user_group_id,
}
provider_id = mapping.provider_id
mapping.delete()
_audit(
request.user,
"delete_group_mapping",
"identity_provider",
str(provider_id),
snapshot,
)
return {"status": "deleted"}
@router.post(
"/providers/{provider_id}/directory-sync",
response=DirectorySyncRunOut,
)
def directory_sync(request, provider_id: int, payload: DirectorySyncIn):
require_permission(request, "system.setting.manage")
if len(payload.records) > 1000:
raise HttpError(400, "单次目录同步最多接收 1000 条记录")
provider = EnterpriseIdentityProvider.objects.get(id=provider_id)
records = [record.dict() for record in payload.records]
run = run_directory_sync(provider, records, request.user)
_audit(
request.user,
"directory_sync",
"identity_provider",
str(provider.id),
{
"run_id": run.id,
"total_count": run.total_count,
"failed_count": run.failed_count,
},
"success" if run.failed_count == 0 else "failed",
)
return run

View File

@@ -0,0 +1,110 @@
from datetime import datetime
from typing import Any
from ninja import Schema
class PublicIdentityProviderOut(Schema):
slug: str
name: str
button_label: str
provider_type: str
class IdentityProviderOut(Schema):
id: int
slug: str
name: str
provider_type: str
enabled: bool
button_label: str
issuer: str
metadata_url: str
client_id: str
credential_set: bool
credential_masked: str
scopes: list[str]
claim_mappings: dict[str, str]
configuration: dict[str, Any]
auto_create_users: bool
sync_profile: bool
sync_groups: bool
identity_count: int
group_mapping_count: int
created_at: datetime
updated_at: datetime
class IdentityProviderCreateIn(Schema):
slug: str
name: str
provider_type: str = "oidc"
enabled: bool = False
button_label: str = ""
issuer: str = ""
metadata_url: str = ""
client_id: str = ""
credential: str = ""
scopes: list[str] = ["openid", "profile", "email"]
claim_mappings: dict[str, str] = {}
configuration: dict[str, Any] = {}
auto_create_users: bool = True
sync_profile: bool = True
sync_groups: bool = True
class IdentityProviderUpdateIn(Schema):
name: str | None = None
enabled: bool | None = None
button_label: str | None = None
issuer: str | None = None
metadata_url: str | None = None
client_id: str | None = None
credential: str | None = None
scopes: list[str] | None = None
claim_mappings: dict[str, str] | None = None
configuration: dict[str, Any] | None = None
auto_create_users: bool | None = None
sync_profile: bool | None = None
sync_groups: bool | None = None
class EnterpriseGroupMappingOut(Schema):
id: int
provider_id: int
external_group: str
user_group_id: int
user_group_name: str
user_group_code: str
created_at: datetime
class EnterpriseGroupMappingIn(Schema):
external_group: str
user_group_id: int
class DirectoryRecordIn(Schema):
subject: str
username: str = ""
email: str = ""
display_name: str = ""
active: bool = True
groups: list[str] = []
class DirectorySyncIn(Schema):
records: list[DirectoryRecordIn]
class DirectorySyncRunOut(Schema):
id: int
provider_id: int
status: str
total_count: int
created_count: int
updated_count: int
failed_count: int
errors: list[dict[str, Any]]
started_at: datetime
finished_at: datetime | None = None

View File

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

View File

@@ -102,6 +102,24 @@ DEFAULT_MENUS: tuple[MenuSpec, ...] = (
40,
"agent.run.view",
),
MenuSpec(
"platform.tools",
"Tool / MCP",
"#/tools",
"settings",
"platform",
50,
"tool.view",
),
MenuSpec(
"platform.channels",
"渠道接入",
"#/channels",
"message",
"platform",
60,
"channel.view",
),
MenuSpec("system", "系统治理", icon="shield", sort_order=50),
MenuSpec(
"system.users", "用户管理", "#/system/users", "users", "system", 10, "system.user.view"
@@ -121,6 +139,15 @@ DEFAULT_MENUS: tuple[MenuSpec, ...] = (
MenuSpec(
"system.menus", "菜单权限", "#/system/menus", "menu", "system", 40, "system.menu.view"
),
MenuSpec("system.audit", "操作审计", "#/audit", "audit", "system", 50, "system.audit.view"),
MenuSpec("system.design", "设计系统", "#/design-system", "settings", "system", 60),
MenuSpec(
"system.identity",
"企业身份",
"#/system/identity",
"shield",
"system",
50,
"system.setting.manage",
),
MenuSpec("system.audit", "操作审计", "#/audit", "audit", "system", 60, "system.audit.view"),
MenuSpec("system.design", "设计系统", "#/design-system", "settings", "system", 70),
)

View File

@@ -0,0 +1,117 @@
# Generated by Django 5.2.15 on 2026-07-27 08:41
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0002_reorganize_navigation_menus'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='EnterpriseIdentityProvider',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('slug', models.SlugField(max_length=80, unique=True)),
('name', models.CharField(max_length=120)),
('provider_type', models.CharField(choices=[('oidc', 'OpenID Connect'), ('ldap', 'LDAP')], default='oidc', max_length=20)),
('enabled', models.BooleanField(default=False)),
('button_label', models.CharField(blank=True, max_length=120)),
('issuer', models.URLField(blank=True, max_length=500)),
('metadata_url', models.URLField(blank=True, max_length=500)),
('client_id', models.CharField(blank=True, max_length=255)),
('credential_encrypted', models.TextField(blank=True)),
('scopes', models.JSONField(blank=True, default=list)),
('claim_mappings', models.JSONField(blank=True, default=dict)),
('configuration', models.JSONField(blank=True, default=dict)),
('auto_create_users', models.BooleanField(default=True)),
('sync_profile', models.BooleanField(default=True)),
('sync_groups', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_identity_providers', to=settings.AUTH_USER_MODEL)),
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_identity_providers', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['name', 'id'],
},
),
migrations.CreateModel(
name='EnterpriseGroupMapping',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('external_group', models.CharField(max_length=255)),
('created_at', models.DateTimeField(auto_now_add=True)),
('user_group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='enterprise_identity_mappings', to='accounts.usergroup')),
('provider', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_mappings', to='accounts.enterpriseidentityprovider')),
],
options={
'ordering': ['provider_id', 'external_group'],
},
),
migrations.CreateModel(
name='EnterpriseDirectorySyncRun',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(choices=[('running', '运行中'), ('completed', '已完成'), ('partial', '部分完成'), ('failed', '失败')], default='running', max_length=20)),
('total_count', models.PositiveIntegerField(default=0)),
('created_count', models.PositiveIntegerField(default=0)),
('updated_count', models.PositiveIntegerField(default=0)),
('failed_count', models.PositiveIntegerField(default=0)),
('errors', models.JSONField(blank=True, default=list)),
('started_at', models.DateTimeField(auto_now_add=True)),
('finished_at', models.DateTimeField(blank=True, null=True)),
('initiated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='enterprise_directory_sync_runs', to=settings.AUTH_USER_MODEL)),
('provider', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='directory_sync_runs', to='accounts.enterpriseidentityprovider')),
],
options={
'ordering': ['-started_at', '-id'],
},
),
migrations.CreateModel(
name='ExternalIdentity',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('subject', models.CharField(max_length=255)),
('claims_snapshot', models.JSONField(blank=True, default=dict)),
('last_login_at', models.DateTimeField(blank=True, null=True)),
('last_synced_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('provider', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='external_identities', to='accounts.enterpriseidentityprovider')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='external_identities', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['provider_id', 'subject'],
},
),
migrations.AddIndex(
model_name='enterpriseidentityprovider',
index=models.Index(fields=['provider_type', 'enabled'], name='acct_idp_type_enabled_idx'),
),
migrations.AddConstraint(
model_name='enterprisegroupmapping',
constraint=models.UniqueConstraint(fields=('provider', 'external_group'), name='acct_idp_external_group_uniq'),
),
migrations.AddIndex(
model_name='enterprisedirectorysyncrun',
index=models.Index(fields=['provider', 'started_at'], name='acct_dirsync_idp_started_idx'),
),
migrations.AddIndex(
model_name='externalidentity',
index=models.Index(fields=['user', 'provider'], name='acct_identity_user_idp_idx'),
),
migrations.AddConstraint(
model_name='externalidentity',
constraint=models.UniqueConstraint(fields=('provider', 'subject'), name='acct_identity_provider_subject_uniq'),
),
migrations.AddConstraint(
model_name='externalidentity',
constraint=models.UniqueConstraint(fields=('provider', 'user'), name='acct_identity_provider_user_uniq'),
),
]

View File

@@ -0,0 +1,36 @@
from django.db import migrations
def add_identity_menu(apps, schema_editor):
menu_item = apps.get_model("accounts", "MenuItem")
parent = menu_item.objects.filter(code="system").first()
menu_item.objects.update_or_create(
code="system.identity",
defaults={
"title": "企业身份",
"path": "#/system/identity",
"icon": "shield",
"parent": parent,
"sort_order": 50,
"permission_code": "system.setting.manage",
"is_visible": True,
"is_enabled": True,
},
)
menu_item.objects.filter(code="system.audit").update(sort_order=60)
menu_item.objects.filter(code="system.design").update(sort_order=70)
def remove_identity_menu(apps, schema_editor):
menu_item = apps.get_model("accounts", "MenuItem")
menu_item.objects.filter(code="system.identity").delete()
menu_item.objects.filter(code="system.audit").update(sort_order=50)
menu_item.objects.filter(code="system.design").update(sort_order=60)
class Migration(migrations.Migration):
dependencies = [("accounts", "0003_enterpriseidentityprovider_enterprisegroupmapping_and_more")]
operations = [
migrations.RunPython(add_identity_menu, remove_identity_menu),
]

View File

@@ -44,3 +44,148 @@ class MenuItem(models.Model):
def __str__(self) -> str:
return self.title
class EnterpriseIdentityProvider(models.Model):
class ProviderType(models.TextChoices):
OIDC = "oidc", "OpenID Connect"
LDAP = "ldap", "LDAP"
slug = models.SlugField(max_length=80, unique=True)
name = models.CharField(max_length=120)
provider_type = models.CharField(
max_length=20,
choices=ProviderType.choices,
default=ProviderType.OIDC,
)
enabled = models.BooleanField(default=False)
button_label = models.CharField(max_length=120, blank=True)
issuer = models.URLField(max_length=500, blank=True)
metadata_url = models.URLField(max_length=500, blank=True)
client_id = models.CharField(max_length=255, blank=True)
credential_encrypted = models.TextField(blank=True)
scopes = models.JSONField(default=list, blank=True)
claim_mappings = models.JSONField(default=dict, blank=True)
configuration = models.JSONField(default=dict, blank=True)
auto_create_users = models.BooleanField(default=True)
sync_profile = models.BooleanField(default=True)
sync_groups = models.BooleanField(default=True)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="created_identity_providers",
)
updated_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="updated_identity_providers",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["name", "id"]
indexes = [
models.Index(fields=["provider_type", "enabled"], name="acct_idp_type_enabled_idx"),
]
def __str__(self) -> str:
return self.name
class ExternalIdentity(models.Model):
provider = models.ForeignKey(
EnterpriseIdentityProvider,
on_delete=models.CASCADE,
related_name="external_identities",
)
subject = models.CharField(max_length=255)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="external_identities",
)
claims_snapshot = models.JSONField(default=dict, blank=True)
last_login_at = models.DateTimeField(null=True, blank=True)
last_synced_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["provider_id", "subject"]
constraints = [
models.UniqueConstraint(
fields=["provider", "subject"],
name="acct_identity_provider_subject_uniq",
),
models.UniqueConstraint(
fields=["provider", "user"],
name="acct_identity_provider_user_uniq",
),
]
indexes = [
models.Index(fields=["user", "provider"], name="acct_identity_user_idp_idx"),
]
class EnterpriseGroupMapping(models.Model):
provider = models.ForeignKey(
EnterpriseIdentityProvider,
on_delete=models.CASCADE,
related_name="group_mappings",
)
external_group = models.CharField(max_length=255)
user_group = models.ForeignKey(
UserGroup,
on_delete=models.CASCADE,
related_name="enterprise_identity_mappings",
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["provider_id", "external_group"]
constraints = [
models.UniqueConstraint(
fields=["provider", "external_group"],
name="acct_idp_external_group_uniq",
),
]
class EnterpriseDirectorySyncRun(models.Model):
class Status(models.TextChoices):
RUNNING = "running", "运行中"
COMPLETED = "completed", "已完成"
PARTIAL = "partial", "部分完成"
FAILED = "failed", "失败"
provider = models.ForeignKey(
EnterpriseIdentityProvider,
on_delete=models.CASCADE,
related_name="directory_sync_runs",
)
status = models.CharField(max_length=20, choices=Status.choices, default=Status.RUNNING)
total_count = models.PositiveIntegerField(default=0)
created_count = models.PositiveIntegerField(default=0)
updated_count = models.PositiveIntegerField(default=0)
failed_count = models.PositiveIntegerField(default=0)
errors = models.JSONField(default=list, blank=True)
initiated_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="enterprise_directory_sync_runs",
)
started_at = models.DateTimeField(auto_now_add=True)
finished_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ["-started_at", "-id"]
indexes = [
models.Index(fields=["provider", "started_at"], name="acct_dirsync_idp_started_idx"),
]

View File

@@ -0,0 +1,72 @@
"""OIDC 客户端构建和安全重定向辅助函数。"""
from urllib.parse import quote
from django.conf import settings
from apps.accounts.models import EnterpriseIdentityProvider
from apps.modelhub.crypto import decrypt_secret
class OIDCConfigurationError(ValueError):
pass
def provider_metadata_url(provider: EnterpriseIdentityProvider) -> str:
if provider.metadata_url:
return provider.metadata_url
if provider.issuer:
return f"{provider.issuer.rstrip('/')}/.well-known/openid-configuration"
raise OIDCConfigurationError("OIDC 提供方缺少发现文档地址或 issuer")
def build_oidc_client(provider: EnterpriseIdentityProvider):
"""构建依赖发现文档和加密凭据的 OIDC 客户端。"""
try:
from authlib.integrations.django_client import OAuth
except ImportError as error:
raise OIDCConfigurationError("服务端未安装 Authlib") from error
if not provider.client_id:
raise OIDCConfigurationError("OIDC 提供方缺少 client_id")
if not provider.credential_encrypted:
raise OIDCConfigurationError("OIDC 提供方缺少 client secret")
scopes = provider.scopes or ["openid", "profile", "email"]
if "openid" not in scopes:
raise OIDCConfigurationError("OIDC scopes 必须包含 openid")
oauth = OAuth()
return oauth.register(
name=f"enterprise_oidc_{provider.pk}",
client_id=provider.client_id,
client_secret=decrypt_secret(provider.credential_encrypted),
server_metadata_url=provider_metadata_url(provider),
client_kwargs={
"scope": " ".join(scopes),
"token_endpoint_auth_method": provider.configuration.get(
"token_endpoint_auth_method",
"client_secret_basic",
),
},
)
def callback_uri(request, provider: EnterpriseIdentityProvider) -> str:
path = f"/api/identity/auth/oidc/{provider.slug}/callback"
if settings.OIDC_CALLBACK_BASE_URL:
return f"{settings.OIDC_CALLBACK_BASE_URL}{path}"
return request.build_absolute_uri(path)
def normalize_return_to(value: str | None) -> str:
"""只允许站内绝对路径,阻断协议相对地址和外部开放重定向。"""
if not value or not value.startswith("/") or value.startswith("//") or len(value) > 500:
return "/"
return value
def frontend_redirect(return_to: str = "/", error: str = "") -> str:
if error:
return f"{settings.FRONTEND_APP_URL}/#/login?auth_error={quote(error[:240])}"
return f"{settings.FRONTEND_APP_URL}/#{normalize_return_to(return_to)}"

View File

@@ -0,0 +1,238 @@
from unittest.mock import patch
import pytest
from django.contrib.auth import get_user_model
from django.core.management import call_command
from django.http import HttpResponseRedirect
from apps.accounts.models import (
EnterpriseGroupMapping,
EnterpriseIdentityProvider,
ExternalIdentity,
UserGroup,
)
from apps.audit.models import OperationAuditLog
from apps.modelhub.crypto import encrypt_secret
pytestmark = pytest.mark.django_db
def login_admin(client):
call_command("bootstrap_governance")
admin = get_user_model().objects.get(username="admin")
client.force_login(admin)
return admin
def oidc_provider(**overrides):
values = {
"slug": "corporate",
"name": "企业统一身份",
"provider_type": "oidc",
"enabled": True,
"issuer": "https://idp.example.com",
"client_id": "ticket-platform",
"credential_encrypted": encrypt_secret("client-secret"),
"scopes": ["openid", "profile", "email"],
"claim_mappings": {},
"configuration": {},
}
values.update(overrides)
return EnterpriseIdentityProvider.objects.create(**values)
def test_provider_management_encrypts_credentials_and_exposes_safe_public_list(client):
login_admin(client)
response = client.post(
"/api/identity/providers",
data={
"slug": "corp-oidc",
"name": "公司 SSO",
"provider_type": "oidc",
"enabled": True,
"issuer": "https://idp.example.com",
"client_id": "ticket-platform",
"credential": "top-secret-value",
"scopes": ["openid", "profile", "email"],
},
content_type="application/json",
)
assert response.status_code == 200
payload = response.json()
assert payload["credential_set"] is True
assert "top-secret-value" not in str(payload)
provider = EnterpriseIdentityProvider.objects.get(slug="corp-oidc")
assert provider.credential_encrypted != "top-secret-value"
client.logout()
public_response = client.get("/api/identity/auth/providers")
assert public_response.status_code == 200
assert public_response.json() == [
{
"slug": "corp-oidc",
"name": "公司 SSO",
"button_label": "使用 公司 SSO 登录",
"provider_type": "oidc",
}
]
def test_provider_rejects_plaintext_secrets_inside_configuration(client):
login_admin(client)
response = client.post(
"/api/identity/providers",
data={
"slug": "unsafe",
"name": "不安全配置",
"provider_type": "ldap",
"configuration": {
"server_uri": "ldaps://directory.example.com",
"bind_password": "must-not-be-plain",
},
},
content_type="application/json",
)
assert response.status_code == 400
assert not EnterpriseIdentityProvider.objects.filter(slug="unsafe").exists()
def test_directory_sync_is_idempotent_and_maps_enterprise_groups(client):
login_admin(client)
provider = oidc_provider(
enabled=False,
claim_mappings={
"subject": "employee.id",
"username": "employee.account",
"email": "employee.mail",
"display_name": "employee.name",
"groups": "organization.memberships",
},
)
support = UserGroup.objects.create(name="一线支持", code="support_l1")
EnterpriseGroupMapping.objects.create(
provider=provider,
external_group="IT-Support",
user_group=support,
)
url = f"/api/identity/providers/{provider.id}/directory-sync"
record = {
"subject": "employee-1001",
"username": "zhangsan",
"email": "zhangsan@example.com",
"display_name": "张三",
"groups": ["IT-Support"],
}
first = client.post(
url,
data={"records": [record]},
content_type="application/json",
)
second = client.post(
url,
data={"records": [{**record, "display_name": "张三(支持)"}]},
content_type="application/json",
)
assert first.status_code == 200
assert first.json()["created_count"] == 1
assert second.status_code == 200
assert second.json()["updated_count"] == 1
assert ExternalIdentity.objects.filter(provider=provider).count() == 1
identity = ExternalIdentity.objects.select_related("user").get(provider=provider)
assert identity.user.first_name == "张三(支持)"
assert identity.user.business_groups.filter(id=support.id).exists()
assert not identity.user.has_usable_password()
def test_oidc_callback_logs_in_user_and_reuses_external_identity(client):
provider = oidc_provider()
support = UserGroup.objects.create(name="企业支持组", code="enterprise_support")
EnterpriseGroupMapping.objects.create(
provider=provider,
external_group="support",
user_group=support,
)
class FakeOIDCClient:
def authorize_access_token(self, request):
return {
"userinfo": {
"sub": "stable-subject",
"preferred_username": "corp-user",
"email": "corp-user@example.com",
"name": "企业用户",
"groups": ["support"],
}
}
session = client.session
session[f"identity_return_to:{provider.slug}"] = "/tickets"
session.save()
callback = f"/api/identity/auth/oidc/{provider.slug}/callback"
with patch(
"apps.accounts.identity_api.build_oidc_client",
return_value=FakeOIDCClient(),
):
first = client.get(callback)
client.logout()
second = client.get(callback)
assert first.status_code == 302
assert first.url.endswith("/#/tickets")
assert second.status_code == 302
assert ExternalIdentity.objects.filter(provider=provider).count() == 1
identity = ExternalIdentity.objects.select_related("user").get(provider=provider)
assert identity.user.business_groups.filter(id=support.id).exists()
assert OperationAuditLog.objects.filter(
action="oidc_login",
target_id=str(identity.id),
).count() == 2
def test_oidc_start_uses_server_side_client_and_callback_failure_is_safe(client):
provider = oidc_provider()
class FakeOIDCClient:
def authorize_redirect(self, request, redirect_uri):
assert redirect_uri.endswith(f"/api/identity/auth/oidc/{provider.slug}/callback")
return HttpResponseRedirect("https://idp.example.com/authorize?state=managed")
def authorize_access_token(self, request):
return {"access_token": "must-never-be-audited"}
with patch(
"apps.accounts.identity_api.build_oidc_client",
return_value=FakeOIDCClient(),
):
start = client.get(
f"/api/identity/auth/oidc/{provider.slug}/start",
{"return_to": "//evil.example.com"},
)
callback = client.get(f"/api/identity/auth/oidc/{provider.slug}/callback")
assert start.status_code == 302
assert start.url.startswith("https://idp.example.com/authorize")
assert callback.status_code == 302
assert "auth_error=" in callback.url
audit = OperationAuditLog.objects.get(action="oidc_callback_failed")
assert "must-never-be-audited" not in str(audit.request_snapshot)
def test_identity_governance_requires_permission(client):
provider = oidc_provider(enabled=False)
assert client.get("/api/identity/providers").status_code == 401
assert (
client.post(
f"/api/identity/providers/{provider.id}/directory-sync",
data={"records": []},
content_type="application/json",
).status_code
== 401
)