feat: 添加飞书用户模块和订阅功能支持 - 新增feishu_users模块用于处理飞书用户身份验证和权限管理 - 新增subscriptions模块用于处理订阅相关功能 - 新增personalization模块用于个性化服务 - 在alembic迁移配置中注册新的模型模块 - 在API路由器中添加feishu_users和subscriptions路由 - 实现事件调度服务的改进,包括错误处理和状态更新优化 - 添加飞书命令处理的权限检查机制 - 实现飞书应用票据事件处理 - 改进审计日志记录功能 ```
325 lines
11 KiB
Python
325 lines
11 KiB
Python
from collections.abc import Sequence
|
|
|
|
from fastapi import HTTPException, status
|
|
from sqlalchemy import delete, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core.config import get_settings
|
|
from app.core.utils.time import utc_now
|
|
from app.modules.audit.constants import AuditRiskLevel, AuditSource, AuditStatus
|
|
from app.modules.audit.models import AuditLog
|
|
from app.modules.feishu_users.constants import (
|
|
FEISHU_USER_NOT_FOUND,
|
|
LAST_ACTIVE_ADMIN_ERROR,
|
|
FeishuCapability,
|
|
FeishuUserRole,
|
|
FeishuUserStatus,
|
|
parse_admin_identities,
|
|
)
|
|
from app.modules.feishu_users.bootstrap import admin_bootstrap_identity_hash
|
|
from app.modules.feishu_users.models import (
|
|
FeishuAdminBootstrapTombstone,
|
|
FeishuUser,
|
|
)
|
|
from app.modules.feishu_users.principal import FeishuPrincipal
|
|
from app.modules.personalization.schemas import ErasureConfirmation, ErasureResult
|
|
from app.modules.personalization.services.erasure import (
|
|
ErasureHook,
|
|
PersonalDataErasureService,
|
|
)
|
|
from app.modules.subscriptions.models import PushDelivery, PushSubscription
|
|
|
|
PERSONAL_DATA_ERASURE_ACTION = "feishu.user.personal_data_erased"
|
|
|
|
|
|
class FeishuPersonalDataService:
|
|
"""Coordinate Feishu identity erasure across personal-data domains."""
|
|
|
|
def __init__(
|
|
self,
|
|
db: Session,
|
|
*,
|
|
extra_hooks: Sequence[ErasureHook] = (),
|
|
) -> None:
|
|
self.db = db
|
|
self.extra_hooks = tuple(extra_hooks)
|
|
self.erasure = PersonalDataErasureService(db)
|
|
|
|
def request_confirmation(
|
|
self,
|
|
principal: FeishuPrincipal,
|
|
) -> ErasureConfirmation:
|
|
"""Issue a short-lived confirmation code for the authenticated user."""
|
|
|
|
principal.require_capability(FeishuCapability.PERSONAL_DATA)
|
|
user = self._find_principal_user(principal)
|
|
if user.status != FeishuUserStatus.ACTIVE:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Feishu user is disabled",
|
|
)
|
|
return self.erasure.request_confirmation(user.id)
|
|
|
|
def confirm(
|
|
self,
|
|
principal: FeishuPrincipal,
|
|
confirmation_code: str,
|
|
) -> ErasureResult:
|
|
"""Confirm and erase the authenticated user's personal data."""
|
|
|
|
principal.require_capability(FeishuCapability.PERSONAL_DATA)
|
|
user = self._lock_principal_user(principal)
|
|
if user.status != FeishuUserStatus.ACTIVE:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Feishu user is disabled",
|
|
)
|
|
return self._confirm_and_erase(
|
|
user,
|
|
confirmation_code,
|
|
audit_source=AuditSource.FEISHU,
|
|
)
|
|
|
|
def erase_by_user_code(self, code: str, *, actor: str) -> ErasureResult:
|
|
"""Erase a user selected by an authenticated internal service."""
|
|
|
|
if not actor.strip():
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="Authenticated service actor is required",
|
|
)
|
|
user = self._lock_user(code=code)
|
|
confirmation = self.erasure.request_confirmation(user.id)
|
|
|
|
# request_confirmation commits by design. Lock and re-check the last-admin
|
|
# invariant in the deletion transaction before any personal row is removed.
|
|
user = self._lock_user(code=code)
|
|
return self._confirm_and_erase(
|
|
user,
|
|
confirmation.confirmation_code,
|
|
audit_source=AuditSource.API,
|
|
)
|
|
|
|
def _find_principal_user(self, principal: FeishuPrincipal) -> FeishuUser:
|
|
user = self.db.execute(
|
|
select(FeishuUser).where(
|
|
FeishuUser.id == principal.owner_id,
|
|
FeishuUser.code == principal.user_code,
|
|
FeishuUser.tenant_key == principal.tenant_key,
|
|
FeishuUser.open_id == principal.open_id,
|
|
)
|
|
).scalar_one_or_none()
|
|
if user is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=FEISHU_USER_NOT_FOUND,
|
|
)
|
|
return user
|
|
|
|
def _lock_principal_user(self, principal: FeishuPrincipal) -> FeishuUser:
|
|
return self._lock_user(
|
|
owner_id=principal.owner_id,
|
|
code=principal.user_code,
|
|
tenant_key=principal.tenant_key,
|
|
open_id=principal.open_id,
|
|
)
|
|
|
|
def _lock_user(
|
|
self,
|
|
*,
|
|
owner_id: int | None = None,
|
|
code: str | None = None,
|
|
tenant_key: str | None = None,
|
|
open_id: str | None = None,
|
|
) -> FeishuUser:
|
|
active_admin_ids = list(
|
|
self.db.execute(
|
|
select(FeishuUser.id)
|
|
.where(
|
|
FeishuUser.role == FeishuUserRole.ADMIN,
|
|
FeishuUser.status == FeishuUserStatus.ACTIVE,
|
|
)
|
|
.order_by(FeishuUser.id.asc())
|
|
.with_for_update()
|
|
).scalars()
|
|
)
|
|
filters = []
|
|
if owner_id is not None:
|
|
filters.append(FeishuUser.id == owner_id)
|
|
if code is not None:
|
|
filters.append(FeishuUser.code == code)
|
|
if tenant_key is not None:
|
|
filters.append(FeishuUser.tenant_key == tenant_key)
|
|
if open_id is not None:
|
|
filters.append(FeishuUser.open_id == open_id)
|
|
user = self.db.execute(
|
|
select(FeishuUser).where(*filters).with_for_update()
|
|
).scalar_one_or_none()
|
|
if user is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=FEISHU_USER_NOT_FOUND,
|
|
)
|
|
if (
|
|
user.role == FeishuUserRole.ADMIN
|
|
and user.status == FeishuUserStatus.ACTIVE
|
|
and active_admin_ids == [user.id]
|
|
):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail=LAST_ACTIVE_ADMIN_ERROR,
|
|
)
|
|
return user
|
|
|
|
def _confirm_and_erase(
|
|
self,
|
|
user: FeishuUser,
|
|
confirmation_code: str,
|
|
*,
|
|
audit_source: str,
|
|
) -> ErasureResult:
|
|
identifiers = tuple(
|
|
sorted(
|
|
{
|
|
value
|
|
for value in (
|
|
user.code,
|
|
user.open_id,
|
|
user.union_id,
|
|
user.user_id,
|
|
)
|
|
if value
|
|
},
|
|
key=len,
|
|
reverse=True,
|
|
)
|
|
)
|
|
configured_admins = parse_admin_identities(
|
|
getattr(get_settings(), "feishu_admin_identities", ())
|
|
)
|
|
bootstrap_identity_hash = (
|
|
admin_bootstrap_identity_hash(user.tenant_key, user.open_id)
|
|
if (user.tenant_key, user.open_id) in configured_admins
|
|
else None
|
|
)
|
|
|
|
def delete_subscriptions(
|
|
db: Session,
|
|
owner_id: int,
|
|
_anonymous_id: str,
|
|
) -> dict[str, int]:
|
|
subscription_ids = select(PushSubscription.id).where(
|
|
PushSubscription.owner_id == owner_id
|
|
)
|
|
deliveries = _row_count(
|
|
db.execute(
|
|
delete(PushDelivery).where(
|
|
PushDelivery.subscription_id.in_(subscription_ids)
|
|
)
|
|
).rowcount
|
|
)
|
|
subscriptions = _row_count(
|
|
db.execute(
|
|
delete(PushSubscription).where(
|
|
PushSubscription.owner_id == owner_id
|
|
)
|
|
).rowcount
|
|
)
|
|
return {
|
|
"deliveries": deliveries,
|
|
"subscriptions": subscriptions,
|
|
}
|
|
|
|
def finalize_identity(
|
|
db: Session,
|
|
_owner_id: int,
|
|
anonymous_id: str,
|
|
) -> dict[str, int]:
|
|
# Flush the pending confirmation-request deletion before removing
|
|
# the identity that owns it.
|
|
db.flush()
|
|
anonymized_logs = _anonymize_audit_logs(
|
|
db,
|
|
identifiers=identifiers,
|
|
anonymous_id=anonymous_id,
|
|
)
|
|
if bootstrap_identity_hash is not None:
|
|
existing_tombstone = db.scalar(
|
|
select(FeishuAdminBootstrapTombstone.id).where(
|
|
FeishuAdminBootstrapTombstone.identity_hash
|
|
== bootstrap_identity_hash
|
|
)
|
|
)
|
|
if existing_tombstone is None:
|
|
db.add(
|
|
FeishuAdminBootstrapTombstone(
|
|
identity_hash=bootstrap_identity_hash
|
|
)
|
|
)
|
|
db.delete(user)
|
|
db.flush()
|
|
db.add(
|
|
AuditLog(
|
|
actor=anonymous_id,
|
|
source=audit_source,
|
|
action=PERSONAL_DATA_ERASURE_ACTION,
|
|
target_type=None,
|
|
target_id=None,
|
|
risk_level=AuditRiskLevel.HIGH,
|
|
request_payload=None,
|
|
response_payload=None,
|
|
status=AuditStatus.SUCCESS,
|
|
request_id=None,
|
|
created_at=utc_now(),
|
|
)
|
|
)
|
|
return {
|
|
"audit_logs_anonymized": anonymized_logs,
|
|
"identity": 1,
|
|
}
|
|
|
|
return self.erasure.confirm_and_erase(
|
|
user.id,
|
|
confirmation_code,
|
|
before_hooks=(delete_subscriptions,),
|
|
extra_hooks=(*self.extra_hooks, finalize_identity),
|
|
)
|
|
|
|
|
|
def _anonymize_audit_logs(
|
|
db: Session,
|
|
*,
|
|
identifiers: Sequence[str],
|
|
anonymous_id: str,
|
|
) -> int:
|
|
if not identifiers:
|
|
return 0
|
|
changed_count = 0
|
|
for record in db.execute(select(AuditLog)).scalars():
|
|
searchable_values = (
|
|
record.actor,
|
|
record.target_id,
|
|
record.request_payload,
|
|
record.response_payload,
|
|
)
|
|
if not any(
|
|
identifier in value
|
|
for value in searchable_values
|
|
if value is not None
|
|
for identifier in identifiers
|
|
):
|
|
continue
|
|
record.actor = anonymous_id
|
|
record.target_type = None
|
|
record.target_id = None
|
|
record.request_payload = None
|
|
record.response_payload = None
|
|
record.request_id = None
|
|
changed_count += 1
|
|
db.flush()
|
|
return changed_count
|
|
|
|
|
|
def _row_count(value: int | None) -> int:
|
|
return max(0, int(value or 0))
|