feat(权限): 业务分组对全员开放(操作分层)+ 操作审计仅超管可见
需求:业务分组开放给所有人,主管可操作、其余人只读;操作审计前端不显示。
⚠️ 这里有个必须收窄的边界:按数据范围规则,被分进组的 SUPERVISOR 会从
「全厂」降级为只看本组。若允许他改可见范围,他把自己那组改成「生产+售后」
就恢复全厂视野;若允许建组,他新建一个组再把自己塞进去,同样绕过。
**「能管成员」与「能配范围」必须分开** —— 前者安全(组长给自己加组会被
唯一约束挡住、移出自己只是失去权限),后者是提权入口。
因此分层如下:
| 谁 | 看 | 改 |
|------------------|--------------|------------------------------------|
| SUPER_ADMIN | 所有组 | 全部(建/改/删组、配范围、管成员) |
| 主管 / 本组组长 | 自己所属的组 | **仅本组成员**(加人/移人/设组长) |
| 普通成员 | 自己所属的组 | 无(只读) |
后端(groups.py):
· 去掉路由级的 require_roles(SUPER_ADMIN),改为逐端点校验
· list_groups / get_group:非超管只返回自己所属的组;看别组详情 403
(连「这个组存在但你没份」都不暴露,避免被拿来推测组织架构)
· create/update/delete:_require_super —— 这三个 + 配范围是提权入口
· add/update/remove member:_can_manage_members(超管 / 主管 / 本组组长)
· GroupOut 新增 can_manage_members / can_manage_group / is_my_leader
—— 由服务端算好下发,前端不按 role 自行推导(组长身份是按组算的)
前端:
· AdminGroupsPage 去掉整页超管门禁,改为按能力显示按钮:
无权限时「组长」列退化成纯展示而非可点按钮
没有任何组时给「你还没有被分配到任何业务分组」的引导提示
· AdminLayout 的 MENU 支持 superOnly 标记,「操作审计」只对超管显示
(后端 /audit/* 本就挂了 require_admin,这里只是不给入口,
否则普通用户点进去只会看到一堆 403)
实测(四类账号):
建组/改范围:仅超管通过,其余全 403
管成员:超管/主管/本组组长通过;非本组 403;跨组组长 403
可见性:主管未分组看到 0 个组、维修组长只看到维修大组、生产组员只看到生产小组
详情页能力标记正确下发;越权查别组详情 403
This commit is contained in:
@ -1,13 +1,20 @@
|
||||
"""业务分组管理 API —— **仅超级管理员**可访问
|
||||
"""业务分组 API —— 全员可见,操作按角色分层
|
||||
|
||||
⚠️ 为什么只有 SUPER_ADMIN 能管分组,SUPERVISOR 不行:
|
||||
权限模型(2026-09 调整:从「仅超管」放开为分层,但**操作边界必须收窄**):
|
||||
|
||||
按数据范围规则,被显式分进组的 SUPERVISOR 会从「全厂」**降级**为只看本组。
|
||||
如果允许 SUPERVISOR 管理分组,那么他被分组之后,只要把自己从组里移出去
|
||||
就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他完全无效。
|
||||
| 谁 | 看 | 改 |
|
||||
|---------------------|------------------------|--------------------------------------|
|
||||
| SUPER_ADMIN | 所有组 | 全部(建组/改组/删组/配范围/管成员) |
|
||||
| 主管(SUPERVISOR) | 自己所属的组 | **仅本组成员**(加人/移人/设组长) |
|
||||
| 组长(is_leader) | 自己所属的组 | **仅本组成员** |
|
||||
| 普通成员 | 自己所属的组 | 无(只读) |
|
||||
|
||||
所以这里用 require_roles(SUPER_ADMIN),**不能**用 require_admin
|
||||
(后者含 SUPERVISOR)。
|
||||
⚠️ **为什么「主管」不能改可见范围、不能建删组**:
|
||||
按数据范围规则,被分进组的 SUPERVISOR 会从「全厂」降级为只看本组。
|
||||
若允许他改范围,他只要把自己那组改成「生产 + 售后」就恢复全厂视野;
|
||||
若能建组,他新建一个组再把自己塞进去,同样绕过。**能给自己加组 = 提权**。
|
||||
所以「管成员」与「配范围」必须分开:前者安全(组长给自己加组会被唯一约束
|
||||
挡住,移出自己只是失去权限),后者是提权入口。
|
||||
|
||||
写操作会被 audit_middleware 自动采集 —— 分组变更是高权限动作,追责必须有据。
|
||||
"""
|
||||
@ -19,10 +26,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import require_roles
|
||||
from app.core.lifecycle import PHASE_LABELS
|
||||
from app.core.mom_database import MomSessionLocal
|
||||
from app.core.roles import SUPER_ADMIN
|
||||
from app.core.roles import ADMIN_ROLES, SUPER_ADMIN
|
||||
from app.services.auth_service import get_current_user
|
||||
from app.models.business_group import (
|
||||
BusinessGroup,
|
||||
BusinessGroupMember,
|
||||
@ -40,11 +47,71 @@ from app.schemas.group import (
|
||||
PhaseOption,
|
||||
)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/groups",
|
||||
tags=["业务分组"],
|
||||
dependencies=[Depends(require_roles(SUPER_ADMIN))],
|
||||
)
|
||||
router = APIRouter(prefix="/groups", tags=["业务分组"])
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 权限判定
|
||||
# ============================================================
|
||||
|
||||
def _is_super(user: dict) -> bool:
|
||||
return (user or {}).get("role") == SUPER_ADMIN
|
||||
|
||||
|
||||
async def _my_group_ids(db: AsyncSession, user: dict) -> set[int]:
|
||||
"""当前用户所属(且启用中)的组 id"""
|
||||
username = (user or {}).get("username") or ""
|
||||
if not username:
|
||||
return set()
|
||||
rows = await db.execute(
|
||||
select(BusinessGroupMember.group_id)
|
||||
.join(BusinessGroup, BusinessGroup.id == BusinessGroupMember.group_id)
|
||||
.where(
|
||||
BusinessGroupMember.user_id == username,
|
||||
BusinessGroup.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
return {r[0] for r in rows.all()}
|
||||
|
||||
|
||||
async def _leader_group_ids(db: AsyncSession, user: dict) -> set[int]:
|
||||
"""当前用户当组长的组 id"""
|
||||
username = (user or {}).get("username") or ""
|
||||
if not username:
|
||||
return set()
|
||||
rows = await db.execute(
|
||||
select(BusinessGroupMember.group_id)
|
||||
.join(BusinessGroup, BusinessGroup.id == BusinessGroupMember.group_id)
|
||||
.where(
|
||||
BusinessGroupMember.user_id == username,
|
||||
BusinessGroupMember.is_leader.is_(True),
|
||||
BusinessGroup.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
return {r[0] for r in rows.all()}
|
||||
|
||||
|
||||
async def _can_manage_members(db: AsyncSession, user: dict, group_id: int) -> bool:
|
||||
"""能否管理**这个组**的成员。
|
||||
|
||||
超管、部门主管(SUPERVISOR)、本组组长 —— 三者之一即可。
|
||||
⚠️ 组长只能管自己当组长的组;主管虽然能管所有组的成员,但**改不了范围**
|
||||
(范围相关端点单独限超管),所以不构成提权。
|
||||
"""
|
||||
if _is_super(user):
|
||||
return True
|
||||
if (user or {}).get("role") in ADMIN_ROLES:
|
||||
return True
|
||||
return group_id in await _leader_group_ids(db, user)
|
||||
|
||||
|
||||
def _require_super(user: dict) -> None:
|
||||
"""建组 / 改组 / 删组 / 配范围 —— 这些是提权入口,只给超管"""
|
||||
if not _is_super(user):
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
"只有超级管理员可以新建、修改或删除分组,以及调整分组的可见范围",
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
@ -94,7 +161,14 @@ async def _member_counts(db: AsyncSession) -> dict[int, int]:
|
||||
|
||||
|
||||
def _to_out(group: BusinessGroup, phases: list[str], count: int,
|
||||
parent_name: str | None, own_phases: list[str]) -> GroupOut:
|
||||
parent_name: str | None, own_phases: list[str],
|
||||
*, can_manage_members: bool = False, can_manage_group: bool = False,
|
||||
is_my_leader: bool = False) -> GroupOut:
|
||||
"""把 ORM 对象转成响应。
|
||||
|
||||
能力标记(能否管成员 / 能否改组)由服务端算好下发,前端不要自己按 role 推导 ——
|
||||
组长身份是按组算的,前端凭 role 推不出来。
|
||||
"""
|
||||
return GroupOut(
|
||||
id=group.id,
|
||||
name=group.name,
|
||||
@ -107,6 +181,9 @@ def _to_out(group: BusinessGroup, phases: list[str], count: int,
|
||||
effective_phases=phases,
|
||||
phase_labels=[PHASE_LABELS.get(p, p) for p in phases],
|
||||
member_count=count,
|
||||
can_manage_members=can_manage_members,
|
||||
can_manage_group=can_manage_group,
|
||||
is_my_leader=is_my_leader,
|
||||
)
|
||||
|
||||
|
||||
@ -123,7 +200,7 @@ async def _get_group_or_404(db: AsyncSession, group_id: int) -> BusinessGroup:
|
||||
# ============================================================
|
||||
|
||||
@router.get("/phase-options", response_model=list[PhaseOption])
|
||||
async def list_phase_options():
|
||||
async def list_phase_options(current_user: dict = Depends(get_current_user)):
|
||||
"""可选的生命周期阶段 —— 供前端渲染勾选框,避免前端写死这两个值"""
|
||||
return [PhaseOption(value=v, label=l) for v, l in _valid_phases().items()]
|
||||
|
||||
@ -132,6 +209,7 @@ async def list_phase_options():
|
||||
def list_member_candidates(
|
||||
keyword: str = Query("", description="按姓名/账号模糊搜索"),
|
||||
limit: int = Query(500, ge=1, le=1000),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""候选人下拉 —— 复用与 users.py 一致的 MOM 查询口径(部门已钉死为 ORG_DEPARTMENT)。
|
||||
|
||||
@ -176,16 +254,34 @@ def list_member_candidates(
|
||||
# ============================================================
|
||||
|
||||
@router.get("", response_model=list[GroupOut])
|
||||
async def list_groups(db: AsyncSession = Depends(get_db)):
|
||||
"""列出全部业务分组(含停用的),带成员数与生效范围"""
|
||||
groups = (await db.execute(
|
||||
select(BusinessGroup).order_by(BusinessGroup.sort_order, BusinessGroup.id)
|
||||
)).scalars().all()
|
||||
async def list_groups(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""列出业务分组。
|
||||
|
||||
- 超管:全部(含停用的)
|
||||
- 其他人:**只返回自己所属的组** —— 普通成员不该看到别人的组有哪些人。
|
||||
连"这个组存在但你没份"都不暴露,避免被拿来推测组织架构。
|
||||
"""
|
||||
stmt = select(BusinessGroup).order_by(BusinessGroup.sort_order, BusinessGroup.id)
|
||||
if not _is_super(current_user):
|
||||
mine = await _my_group_ids(db, current_user)
|
||||
if not mine:
|
||||
return []
|
||||
stmt = stmt.where(BusinessGroup.id.in_(tuple(mine)))
|
||||
|
||||
groups = (await db.execute(stmt)).scalars().all()
|
||||
|
||||
own = await _load_phases(db, [g.id for g in groups])
|
||||
counts = await _member_counts(db)
|
||||
names = {g.id: g.name for g in groups}
|
||||
|
||||
# 能力标记一次算好,避免在循环里反复查库
|
||||
is_super = _is_super(current_user)
|
||||
is_admin_role = (current_user or {}).get("role") in ADMIN_ROLES
|
||||
leader_ids = set() if is_super else await _leader_group_ids(db, current_user)
|
||||
|
||||
return [
|
||||
_to_out(
|
||||
g,
|
||||
@ -193,15 +289,28 @@ async def list_groups(db: AsyncSession = Depends(get_db)):
|
||||
counts.get(g.id, 0),
|
||||
names.get(g.parent_id) if g.parent_id else None,
|
||||
own.get(g.id, []),
|
||||
can_manage_members=is_super or is_admin_role or g.id in leader_ids,
|
||||
can_manage_group=is_super,
|
||||
is_my_leader=g.id in leader_ids,
|
||||
)
|
||||
for g in groups
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{group_id}", response_model=GroupDetailOut)
|
||||
async def get_group(group_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""分组详情 + 成员列表"""
|
||||
async def get_group(
|
||||
group_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""分组详情 + 成员列表(非超管只能看自己所属的组)"""
|
||||
group = await _get_group_or_404(db, group_id)
|
||||
if not _is_super(current_user):
|
||||
mine = await _my_group_ids(db, current_user)
|
||||
if group_id not in mine:
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN, "你不在该分组中,无法查看"
|
||||
)
|
||||
|
||||
own = await _load_phases(db, [group.id] + ([group.parent_id] if group.parent_id else []))
|
||||
members = (await db.execute(
|
||||
@ -219,12 +328,19 @@ async def get_group(group_id: int, db: AsyncSession = Depends(get_db)):
|
||||
parent = await db.get(BusinessGroup, group.parent_id)
|
||||
parent_name = parent.name if parent else None
|
||||
|
||||
is_super = _is_super(current_user)
|
||||
is_admin_role = (current_user or {}).get("role") in ADMIN_ROLES
|
||||
leader_ids = set() if is_super else await _leader_group_ids(db, current_user)
|
||||
|
||||
base = _to_out(
|
||||
group,
|
||||
await _effective_phases(db, group, own),
|
||||
len(members),
|
||||
parent_name,
|
||||
own.get(group.id, []),
|
||||
can_manage_members=is_super or is_admin_role or group.id in leader_ids,
|
||||
can_manage_group=is_super,
|
||||
is_my_leader=group.id in leader_ids,
|
||||
)
|
||||
return GroupDetailOut(
|
||||
**base.model_dump(),
|
||||
@ -240,8 +356,16 @@ async def get_group(group_id: int, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("", response_model=GroupOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_group(payload: GroupCreate, db: AsyncSession = Depends(get_db)):
|
||||
"""新建分组。parent_id 为空即建大组,否则是挂在某个大组下的小组。"""
|
||||
async def create_group(
|
||||
payload: GroupCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""新建分组。parent_id 为空即建大组,否则是挂在某个大组下的小组。
|
||||
|
||||
⚠️ 仅超管 —— 「能建组」意味着「能新建一个组再把自己塞进去」,是提权入口。
|
||||
"""
|
||||
_require_super(current_user)
|
||||
valid = _valid_phases()
|
||||
bad = [p for p in payload.phases if p not in valid]
|
||||
if bad:
|
||||
@ -291,13 +415,21 @@ async def create_group(payload: GroupCreate, db: AsyncSession = Depends(get_db))
|
||||
|
||||
@router.patch("/{group_id}", response_model=GroupOut)
|
||||
async def update_group(
|
||||
group_id: int, payload: GroupUpdate, db: AsyncSession = Depends(get_db),
|
||||
group_id: int,
|
||||
payload: GroupUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""修改分组。
|
||||
"""修改分组(含可见范围)。
|
||||
|
||||
⚠️ **仅超管** —— 这是最关键的一条:`phases` 决定能看到哪些数据,谁能改它
|
||||
谁就能给自己扩权(比如把本组改成「生产 + 售后」= 全厂)。
|
||||
主管能管成员但不能碰范围,就是这个原因。
|
||||
|
||||
⚠️ `is_active=false` 的语义是「该组所有成员**立即**退回未分组状态」——
|
||||
这是一次批量权限变更,前端必须二次确认后再调。
|
||||
"""
|
||||
_require_super(current_user)
|
||||
group = await _get_group_or_404(db, group_id)
|
||||
|
||||
if payload.name is not None and payload.name != group.name:
|
||||
@ -358,13 +490,18 @@ async def update_group(
|
||||
|
||||
|
||||
@router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_group(group_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""删除分组。
|
||||
async def delete_group(
|
||||
group_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""删除分组(仅超管)。
|
||||
|
||||
⚠️ **仅允许删空组**。有成员时返回 409,要求先移除成员或改为停用。
|
||||
级联删除是一次**静默的批量权限变更** —— 误点一下,一批人就突然看不到
|
||||
数据了。强制多走一步,出错时是可见的。
|
||||
"""
|
||||
_require_super(current_user)
|
||||
group = await _get_group_or_404(db, group_id)
|
||||
|
||||
member_count = await db.scalar(
|
||||
@ -398,10 +535,21 @@ async def delete_group(group_id: int, db: AsyncSession = Depends(get_db)):
|
||||
@router.post("/{group_id}/members", response_model=GroupMemberOut,
|
||||
status_code=status.HTTP_201_CREATED)
|
||||
async def add_member(
|
||||
group_id: int, payload: GroupMemberAdd, db: AsyncSession = Depends(get_db),
|
||||
group_id: int,
|
||||
payload: GroupMemberAdd,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""把一个人加进分组。一个人可以在多个组 —— 多组 = 多看一个范围。"""
|
||||
"""把一个人加进分组。一个人可以在多个组 —— 多组 = 多看一个范围。
|
||||
|
||||
权限:超管 / 部门主管 / **本组组长**。组长给自己加组会被唯一约束挡住
|
||||
(他本来就在组里),所以这里不构成提权。
|
||||
"""
|
||||
await _get_group_or_404(db, group_id)
|
||||
if not await _can_manage_members(db, current_user, group_id):
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN, "只有超级管理员、主管或本组组长可以添加成员"
|
||||
)
|
||||
|
||||
exists = await db.scalar(
|
||||
select(BusinessGroupMember.id).where(
|
||||
@ -431,8 +579,13 @@ async def add_member(
|
||||
async def update_member(
|
||||
group_id: int, user_id: str, payload: GroupMemberUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""设置/取消组长。组长数据范围与组员相同,额外能管理本组成员。"""
|
||||
if not await _can_manage_members(db, current_user, group_id):
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN, "只有超级管理员、主管或本组组长可以调整组长"
|
||||
)
|
||||
member = (await db.execute(
|
||||
select(BusinessGroupMember).where(
|
||||
BusinessGroupMember.group_id == group_id,
|
||||
@ -455,8 +608,16 @@ async def update_member(
|
||||
|
||||
|
||||
@router.delete("/{group_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def remove_member(group_id: int, user_id: str, db: AsyncSession = Depends(get_db)):
|
||||
async def remove_member(
|
||||
group_id: int, user_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""把成员移出分组。若此人不再属于任何组,将退回「未分组」状态。"""
|
||||
if not await _can_manage_members(db, current_user, group_id):
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN, "只有超级管理员、主管或本组组长可以移除成员"
|
||||
)
|
||||
result = await db.execute(
|
||||
delete(BusinessGroupMember).where(
|
||||
BusinessGroupMember.group_id == group_id,
|
||||
|
||||
Reference in New Issue
Block a user