feat: add tenant-scoped rule and permission management
This commit is contained in:
@@ -8,9 +8,16 @@ from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession
|
||||
from fastapi_common.fastapi_common_web.domain.responses import StatusCodeEnum
|
||||
from fastapi_common.fastapi_common_web.exception.LeauditException import LeauditException
|
||||
|
||||
from fastapi_modules.fastapi_leaudit.domian.vo.homeVo import HomeEntryAreaVO, HomeEntryDocumentTypeVO, HomeEntryModuleVO
|
||||
from fastapi_modules.fastapi_leaudit.domian.vo.homeVo import (
|
||||
HomeEntryAreaVO,
|
||||
HomeEntryDocumentTypeVO,
|
||||
HomeEntryModuleVO,
|
||||
HomeEntryTenantVO,
|
||||
)
|
||||
from fastapi_modules.fastapi_leaudit.services.homeService import IHomeService
|
||||
from fastapi_modules.fastapi_leaudit.services.impl.rbacServiceImpl import RbacServiceImpl
|
||||
from fastapi_modules.fastapi_leaudit.services.impl.ssoUserCompat import SsoUserCompat
|
||||
from fastapi_modules.fastapi_leaudit.services.impl.tenantResolver import TenantResolution, TenantResolver
|
||||
|
||||
|
||||
class HomeServiceImpl(IHomeService):
|
||||
@@ -26,18 +33,37 @@ class HomeServiceImpl(IHomeService):
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.RbacService = RbacServiceImpl()
|
||||
self.TenantResolver = TenantResolver()
|
||||
self._entry_module_tenant_table_exists_cache: bool | None = None
|
||||
|
||||
async def GetEntryModules(self, UserId: int) -> list[HomeEntryModuleVO]:
|
||||
"""获取当前用户可见的首页入口模块。"""
|
||||
allowedPaths = await self._loadAllowedPaths(UserId=UserId)
|
||||
async with GetAsyncSession() as Session:
|
||||
userResult = await Session.execute(
|
||||
allowed_paths = await self._loadAllowedPaths(UserId=UserId)
|
||||
has_tenant_mapping_table = await self._entry_module_tenant_table_exists()
|
||||
|
||||
async with GetAsyncSession() as session:
|
||||
sso_user_columns = await SsoUserCompat.get_columns(session)
|
||||
tenant_code_select = SsoUserCompat.optional_coalesce_as(
|
||||
sso_user_columns,
|
||||
alias="u",
|
||||
column="tenant_code",
|
||||
fallback_sql="''",
|
||||
)
|
||||
tenant_name_select = SsoUserCompat.optional_coalesce_as(
|
||||
sso_user_columns,
|
||||
alias="u",
|
||||
column="tenant_name",
|
||||
fallback_sql="''",
|
||||
)
|
||||
user_result = await session.execute(
|
||||
text(
|
||||
"""
|
||||
f"""
|
||||
SELECT
|
||||
u.id,
|
||||
COALESCE(u.area, '') AS area,
|
||||
COALESCE(bool_or(r.role_key = 'super_admin'), FALSE) AS bypass_area
|
||||
{tenant_code_select},
|
||||
{tenant_name_select},
|
||||
COALESCE(bool_or(r.role_key = 'super_admin'), FALSE) AS bypass_tenant
|
||||
FROM sso_users u
|
||||
LEFT JOIN user_role ur ON ur.user_id = u.id
|
||||
LEFT JOIN roles r ON r.id = ur.role_id
|
||||
@@ -49,18 +75,113 @@ class HomeServiceImpl(IHomeService):
|
||||
),
|
||||
{"user_id": UserId},
|
||||
)
|
||||
userRow = userResult.mappings().first()
|
||||
if not userRow:
|
||||
user_row = user_result.mappings().first()
|
||||
if not user_row:
|
||||
raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "当前用户不存在或已停用")
|
||||
|
||||
result = await Session.execute(
|
||||
text(
|
||||
"""
|
||||
WITH user_roles AS (
|
||||
SELECT ur.role_id
|
||||
FROM user_role ur
|
||||
WHERE ur.user_id = :user_id
|
||||
tenant_resolution = await self.TenantResolver.ResolveUserContext(
|
||||
Area=str(user_row["area"] or ""),
|
||||
TenantCode=str(user_row["tenant_code"] or ""),
|
||||
TenantName=str(user_row["tenant_name"] or ""),
|
||||
Source="home_entry_user",
|
||||
)
|
||||
effective_tenant_code = tenant_resolution.tenant_code or ""
|
||||
effective_tenant_name = (
|
||||
str(tenant_resolution.tenant_name or tenant_resolution.normalized_value or user_row["area"] or "").strip()
|
||||
)
|
||||
legacy_area_candidates = [
|
||||
candidate
|
||||
for candidate in {
|
||||
str(user_row["area"] or "").strip(),
|
||||
effective_tenant_name,
|
||||
}
|
||||
if candidate
|
||||
]
|
||||
tenant_select_sql = (
|
||||
"""
|
||||
COALESCE(
|
||||
(
|
||||
SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'tenant_code', emt.tenant_code,
|
||||
'tenant_name', emt.tenant_name,
|
||||
'enabled', emt.is_enabled,
|
||||
'sort_order', emt.sort_order
|
||||
)
|
||||
ORDER BY emt.sort_order ASC, emt.id ASC
|
||||
)
|
||||
FROM leaudit_entry_module_tenants emt
|
||||
WHERE emt.entry_module_id = em.id
|
||||
AND emt.deleted_at IS NULL
|
||||
),
|
||||
'[]'::jsonb
|
||||
) AS tenants
|
||||
"""
|
||||
if has_tenant_mapping_table
|
||||
else "'[]'::jsonb AS tenants"
|
||||
)
|
||||
tenant_scope_filter_sql = (
|
||||
"""
|
||||
(
|
||||
:bypass_tenant = TRUE
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM leaudit_entry_module_tenants emt
|
||||
WHERE emt.entry_module_id = em.id
|
||||
AND emt.deleted_at IS NULL
|
||||
AND emt.is_enabled = TRUE
|
||||
AND (
|
||||
emt.tenant_code = :user_tenant_code
|
||||
OR emt.tenant_code = 'PUBLIC'
|
||||
)
|
||||
)
|
||||
OR (
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM leaudit_entry_module_tenants emt0
|
||||
WHERE emt0.entry_module_id = em.id
|
||||
AND emt0.deleted_at IS NULL
|
||||
)
|
||||
AND (
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_array_elements(COALESCE(em.areas, '[]'::jsonb)) AS area_item
|
||||
WHERE area_item->>'area' = ANY(:legacy_area_candidates)
|
||||
AND COALESCE((area_item->>'enabled')::boolean, FALSE) = TRUE
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_array_elements(COALESCE(em.areas, '[]'::jsonb)) AS area_item
|
||||
WHERE area_item->>'area' IN ('default', '公共')
|
||||
AND COALESCE((area_item->>'enabled')::boolean, FALSE) = TRUE
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
"""
|
||||
if has_tenant_mapping_table
|
||||
else """
|
||||
(
|
||||
:bypass_tenant = TRUE
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_array_elements(COALESCE(em.areas, '[]'::jsonb)) AS area_item
|
||||
WHERE area_item->>'area' = ANY(:legacy_area_candidates)
|
||||
AND COALESCE((area_item->>'enabled')::boolean, FALSE) = TRUE
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_array_elements(COALESCE(em.areas, '[]'::jsonb)) AS area_item
|
||||
WHERE area_item->>'area' IN ('default', '公共')
|
||||
AND COALESCE((area_item->>'enabled')::boolean, FALSE) = TRUE
|
||||
)
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
result = await session.execute(
|
||||
text(
|
||||
f"""
|
||||
SELECT
|
||||
em.id,
|
||||
em.name,
|
||||
@@ -69,6 +190,7 @@ class HomeServiceImpl(IHomeService):
|
||||
em.icon_path,
|
||||
em.areas,
|
||||
em.sort_order,
|
||||
{tenant_select_sql},
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
@@ -89,24 +211,7 @@ class HomeServiceImpl(IHomeService):
|
||||
ON dt.entry_module_id = em.id
|
||||
WHERE em.deleted_at IS NULL
|
||||
AND em.is_enabled = TRUE
|
||||
AND (
|
||||
:bypass_area = TRUE
|
||||
OR COALESCE(:user_area, '') = ''
|
||||
OR em.areas IS NULL
|
||||
OR jsonb_typeof(em.areas) <> 'array'
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_array_elements(em.areas) AS area_item
|
||||
WHERE area_item->>'area' = :user_area
|
||||
AND COALESCE((area_item->>'enabled')::boolean, FALSE) = TRUE
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_array_elements(em.areas) AS area_item
|
||||
WHERE area_item->>'area' = 'default'
|
||||
AND COALESCE((area_item->>'enabled')::boolean, FALSE) = TRUE
|
||||
)
|
||||
)
|
||||
AND {tenant_scope_filter_sql}
|
||||
GROUP BY
|
||||
em.id,
|
||||
em.name,
|
||||
@@ -119,82 +224,183 @@ class HomeServiceImpl(IHomeService):
|
||||
"""
|
||||
),
|
||||
{
|
||||
"user_id": UserId,
|
||||
"user_area": str(userRow["area"] or ""),
|
||||
"bypass_area": bool(userRow["bypass_area"]),
|
||||
"user_tenant_code": effective_tenant_code,
|
||||
"legacy_area_candidates": legacy_area_candidates,
|
||||
"bypass_tenant": bool(user_row["bypass_tenant"]),
|
||||
},
|
||||
)
|
||||
|
||||
modules: list[HomeEntryModuleVO] = []
|
||||
for row in result.mappings().all():
|
||||
areas: list[HomeEntryAreaVO] = []
|
||||
rawAreas = row["areas"]
|
||||
if isinstance(rawAreas, list):
|
||||
for areaItem in rawAreas:
|
||||
if isinstance(areaItem, dict) and areaItem.get("area"):
|
||||
areas.append(
|
||||
HomeEntryAreaVO(
|
||||
area=str(areaItem["area"]),
|
||||
enabled=bool(areaItem.get("enabled", False)),
|
||||
sortOrder=int(areaItem.get("sort_order", 0)),
|
||||
tenants: list[HomeEntryTenantVO] = []
|
||||
raw_tenants = row["tenants"]
|
||||
if isinstance(raw_tenants, list):
|
||||
for item in raw_tenants:
|
||||
if isinstance(item, dict) and item.get("tenant_code"):
|
||||
tenants.append(
|
||||
HomeEntryTenantVO(
|
||||
tenantCode=str(item["tenant_code"]),
|
||||
tenantName=item.get("tenant_name"),
|
||||
enabled=bool(item.get("enabled", False)),
|
||||
sortOrder=int(item.get("sort_order", 0)),
|
||||
)
|
||||
)
|
||||
if not tenants:
|
||||
raw_areas = row["areas"]
|
||||
if isinstance(raw_areas, list):
|
||||
for index, area_item in enumerate(raw_areas, start=1):
|
||||
if not isinstance(area_item, dict) or not area_item.get("area"):
|
||||
continue
|
||||
resolution = await self._resolveLegacyTenantValue(
|
||||
RawValue=str(area_item.get("area") or ""),
|
||||
Source="home_entry_legacy_area",
|
||||
)
|
||||
tenants.append(
|
||||
HomeEntryTenantVO(
|
||||
tenantCode=resolution.tenant_code,
|
||||
tenantName=resolution.tenant_name,
|
||||
enabled=bool(area_item.get("enabled", False)),
|
||||
sortOrder=int(area_item.get("sort_order", index)),
|
||||
)
|
||||
)
|
||||
tenants.sort(key=lambda item: (item.sortOrder, item.tenantCode))
|
||||
|
||||
documentTypes: list[HomeEntryDocumentTypeVO] = []
|
||||
rawDocumentTypes = row["document_types"]
|
||||
if isinstance(rawDocumentTypes, list):
|
||||
for documentType in rawDocumentTypes:
|
||||
if isinstance(documentType, dict) and documentType.get("id") is not None:
|
||||
documentTypes.append(
|
||||
areas: list[HomeEntryAreaVO] = [
|
||||
HomeEntryAreaVO(
|
||||
area=item.tenantName or item.tenantCode,
|
||||
enabled=item.enabled,
|
||||
sortOrder=item.sortOrder,
|
||||
)
|
||||
for item in tenants
|
||||
]
|
||||
|
||||
document_types: list[HomeEntryDocumentTypeVO] = []
|
||||
raw_document_types = row["document_types"]
|
||||
if isinstance(raw_document_types, list):
|
||||
for document_type in raw_document_types:
|
||||
if isinstance(document_type, dict) and document_type.get("id") is not None:
|
||||
document_types.append(
|
||||
HomeEntryDocumentTypeVO(
|
||||
id=int(documentType["id"]),
|
||||
name=str(documentType["name"]),
|
||||
code=documentType.get("code"),
|
||||
id=int(document_type["id"]),
|
||||
name=str(document_type["name"]),
|
||||
code=document_type.get("code"),
|
||||
)
|
||||
)
|
||||
|
||||
targetPath = self._normalizeTargetPath(
|
||||
target_path = self._normalizeTargetPath(
|
||||
RawPath=str(row["path"] or ""),
|
||||
HasDocumentTypes=len(documentTypes) > 0,
|
||||
HasDocumentTypes=len(document_types) > 0,
|
||||
)
|
||||
if not targetPath:
|
||||
if not target_path:
|
||||
continue
|
||||
|
||||
if not self._isAllowedTargetPath(targetPath, allowedPaths):
|
||||
if not self._isAllowedTargetPath(target_path, allowed_paths):
|
||||
continue
|
||||
|
||||
requiresDocumentTypes = targetPath not in {"/chat-with-llm/chat", "/cross-checking"}
|
||||
requires_document_types = target_path not in {"/chat-with-llm/chat", "/cross-checking"}
|
||||
|
||||
modules.append(
|
||||
HomeEntryModuleVO(
|
||||
id=int(row["id"]),
|
||||
name=str(row["name"]),
|
||||
description=row["description"],
|
||||
targetPath=targetPath,
|
||||
routePath=targetPath,
|
||||
targetPath=target_path,
|
||||
routePath=target_path,
|
||||
iconPath=row["icon_path"],
|
||||
sortOrder=int(row["sort_order"] or 0),
|
||||
requiresDocumentTypes=requiresDocumentTypes,
|
||||
requiresDocumentTypes=requires_document_types,
|
||||
areas=areas,
|
||||
documentTypes=documentTypes,
|
||||
tenants=tenants,
|
||||
documentTypes=document_types,
|
||||
)
|
||||
)
|
||||
|
||||
return modules
|
||||
|
||||
async def _entry_module_tenant_table_exists(self) -> bool:
|
||||
if self._entry_module_tenant_table_exists_cache is not None:
|
||||
return self._entry_module_tenant_table_exists_cache
|
||||
async with GetAsyncSession() as session:
|
||||
exists = bool(
|
||||
(
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = 'leaudit_entry_module_tenants'
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
self._entry_module_tenant_table_exists_cache = exists
|
||||
return exists
|
||||
|
||||
async def _resolveLegacyTenantValue(self, *, RawValue: str, Source: str) -> TenantResolution:
|
||||
resolution = await self.TenantResolver.Resolve(
|
||||
RawValue=RawValue,
|
||||
Source=Source,
|
||||
)
|
||||
if resolution.tenant_code:
|
||||
return resolution
|
||||
|
||||
normalized = str(RawValue or "").strip()
|
||||
if normalized in {"", "default", "省级", "公共"}:
|
||||
public_resolution = await self.TenantResolver.Resolve(
|
||||
RawValue="",
|
||||
Source=Source,
|
||||
)
|
||||
if public_resolution.tenant_code:
|
||||
return public_resolution
|
||||
return TenantResolution(
|
||||
tenant_code="PUBLIC",
|
||||
tenant_name="公共",
|
||||
tenant_type="PUBLIC",
|
||||
raw_value=RawValue,
|
||||
normalized_value=normalized,
|
||||
source=Source,
|
||||
is_public=True,
|
||||
)
|
||||
if normalized == "省局":
|
||||
provincial_resolution = await self.TenantResolver.Resolve(RawValue="省局", Source=Source)
|
||||
if provincial_resolution.tenant_code:
|
||||
return provincial_resolution
|
||||
return TenantResolution(
|
||||
tenant_code="PROVINCIAL",
|
||||
tenant_name="省局",
|
||||
tenant_type="PROVINCIAL",
|
||||
raw_value=RawValue,
|
||||
normalized_value=normalized,
|
||||
source=Source,
|
||||
is_public=False,
|
||||
)
|
||||
return TenantResolution(
|
||||
tenant_code=normalized or None,
|
||||
tenant_name=normalized or None,
|
||||
tenant_type="LEGACY_AREA" if normalized else None,
|
||||
raw_value=RawValue,
|
||||
normalized_value=normalized,
|
||||
source=Source,
|
||||
is_public=False,
|
||||
)
|
||||
|
||||
async def _loadAllowedPaths(self, UserId: int) -> set[str]:
|
||||
"""加载当前用户在首页可点击的目标路径集合。"""
|
||||
routesVo = await self.RbacService.GetCurrentUserRoutes(UserId=UserId)
|
||||
allowedPaths: set[str] = set()
|
||||
routes_vo = await self.RbacService.GetCurrentUserRoutes(UserId=UserId)
|
||||
allowed_paths: set[str] = set()
|
||||
|
||||
def collect(items) -> None:
|
||||
for item in items:
|
||||
allowedPaths.add(str(item.route_path or ""))
|
||||
allowed_paths.add(str(item.route_path or ""))
|
||||
if item.children:
|
||||
collect(item.children)
|
||||
|
||||
collect(routesVo.routes)
|
||||
return {path for path in allowedPaths if path}
|
||||
collect(routes_vo.routes)
|
||||
return {path for path in allowed_paths if path}
|
||||
|
||||
def _isAllowedTargetPath(self, TargetPath: str, AllowedPaths: set[str]) -> bool:
|
||||
"""判断首页目标路径是否被当前用户路由树覆盖。"""
|
||||
@@ -212,8 +418,8 @@ class HomeServiceImpl(IHomeService):
|
||||
return "/files/upload"
|
||||
|
||||
if any(
|
||||
RawPath == enabledPath or RawPath.startswith(f"{enabledPath}/")
|
||||
for enabledPath in self._MINIMAL_ENABLED_TARGETS
|
||||
RawPath == enabled_path or RawPath.startswith(f"{enabled_path}/")
|
||||
for enabled_path in self._MINIMAL_ENABLED_TARGETS
|
||||
):
|
||||
return RawPath
|
||||
|
||||
|
||||
Reference in New Issue
Block a user