PayrollManagementSystem/backend/app/services/authorization_management.py
liuwenxin 1461a8d113 feat(后端): 添加授权体系、系统监控与试用期计提
按 2026-11-02 需求补齐计划交付后端能力:
- 角色授权:权限目录化,接口统一准入校验,未登记默认拒绝;
  管理员判断改为按数据库角色重新判定,防止权限码持有者绕过
- 系统监控:单进程采集资源、请求与异常指标,分钟级聚合,
  阈值配置化;敏感配置增加 repr 保护,Redis 密码 URL 转义
- 试用期计提:按回款日判定并写入快照,年度结清与调整单联动;
  人员模型补试用期、离职日期与工资版本历史字段
- 计提模板:公式骨架受控,页面维护名称与参数绑定
- T+ 采集:自动化配置表与进度查询

内置角色禁停用保护对齐模块注释;核算规则版本提为常量并
补充批量核算失败堆栈日志。

影响范围:后端全部模块、数据库迁移 V22-V28
验证:uv run ruff check 通过;922 个单元测试全部通过;
集成测试因本机 MySQL 数据卷被其他容器锁定暂未运行
2026-11-03 11:20:55 +08:00

172 lines
7.7 KiB
Python

"""管理员维护授权;审计、版本检查和权限写入在同一业务事务中。"""
from sqlalchemy import delete, select
from app.core.exceptions import BadRequestError, ConflictError, ForbiddenError, NotFoundError
from app.models import Permission, Role, RolePermission, User, UserRole
from app.repositories import user_repository
from app.services import audit_service
from app.services import authorization_service as auth
from app.services.audit_service import AuditRecord
from app.services.permission_catalog import CATALOG
def require_admin(session, actor):
if actor is None or actor.status != 1 or "admin" not in auth.role_codes(session, actor.id, for_update=True):
raise ForbiddenError("仅系统管理员可以配置权限")
def lock_policy(session, actor, expected=None):
policy = auth.setting(session, lock=True)
require_admin(session, actor)
if expected is not None and policy.revision != expected:
raise ConflictError()
return policy
def bump(policy, actor):
policy.revision += 1
policy.updated_by = actor.id
def _user(session, user_id):
user = session.get(User, user_id)
if user is None:
raise NotFoundError("用户不存在")
return user
def _roles(session, codes, *, existing=()):
if len(set(codes)) != len(codes):
raise BadRequestError("角色不能重复")
rows = list(session.scalars(select(Role).where(Role.role_code.in_(codes))))
if len(rows) != len(codes) or any(r.status != 1 and r.role_code not in existing for r in rows):
raise BadRequestError("角色不存在或已停用")
return rows
def _overrides(payload):
if payload.overrides:
raise BadRequestError("已改为角色授权,请在角色管理中配置权限")
return {}
def user_authorization(session, user_id):
user = _user(session, user_id)
result = auth.calculate(session, user)
return {**result, "id": user.id, "username": user.username,
"role_codes": [link.role.role_code for link in user.user_roles],
"overrides": [], "revision": auth.setting(session).revision,
"is_payroll_maker": auth.is_maker(session, user)}
def preview(session, user_id, payload):
user = _user(session, user_id)
_roles(session, payload.role_codes, existing=[link.role.role_code for link in user.user_roles])
overrides = _overrides(payload)
if "admin" in payload.role_codes and overrides:
raise BadRequestError("管理员权限为固定基线,不能设置个人覆盖")
proposed = auth.calculate(session, user, roles=payload.role_codes, overrides=overrides)
before = set(auth.calculate(session, user)["permissions"])
after = set(proposed["permissions"])
return {**proposed, "added": sorted(after - before), "removed": sorted(before - after)}
def guard_user_change(session, user, actor, *, roles=None, status=None, deleting=False):
"""调用方已持全局授权锁;防止两个管理员并发把系统管理资格撤空。"""
session.refresh(user, with_for_update=True)
current = auth.role_codes(session, user.id, for_update=True)
removes_admin = "admin" in current and (
deleting or status == 0 or (roles is not None and "admin" not in roles)
)
if removes_admin:
if actor.id == user.id:
raise ForbiddenError("不能撤销自己的管理员资格或停用自己")
active = list(session.scalars(select(User.id).join(UserRole).join(Role).where(
User.status == 1, Role.role_code == "admin", Role.status == 1
).with_for_update()))
if len(active) <= 1:
raise ForbiddenError("不能移除最后一个启用的管理员")
def save_user(session, user_id, payload, actor, meta=None):
audit_data = payload.model_dump(mode="json")
with audit_service.audit(session, operator=actor, meta=meta, record=AuditRecord(
"权限管理", "用户授权", f"修改用户 #{user_id} 的权限", audit_data
)):
policy = lock_policy(session, actor, payload.expected_revision)
user = _user(session, user_id)
before = user_authorization(session, user_id)
changes = preview(session, user_id, payload)
guard_user_change(session, user, actor, roles=payload.role_codes)
roles = _roles(session, payload.role_codes, existing=[link.role.role_code for link in user.user_roles])
user_repository.replace_roles(session, user, roles)
bump(policy, actor)
session.flush()
result = user_authorization(session, user_id)
result["changes"] = {"added": changes["added"], "removed": changes["removed"],
"before_roles": before["role_codes"], "after_roles": result["role_codes"]}
audit_data["changes"] = result["changes"]
session.commit()
return result
def role_permissions(session, role_id):
role = session.get(Role, role_id)
if role is None:
raise NotFoundError("角色不存在")
codes = sorted(CATALOG) if role.role_code == "admin" else list(session.scalars(
select(Permission.perm_code).join(RolePermission, RolePermission.permission_id == Permission.id)
.where(RolePermission.role_id == role.id, Permission.perm_code.in_(CATALOG))
))
return {"role_id": role.id, "role_code": role.role_code, "role_name": role.role_name,
"permission_codes": codes, "revision": auth.setting(session).revision,
"fixed": role.role_code == "admin"}
def validate_role_permissions(permission_codes):
"""新建与修改角色共用同一权限目录、管理员边界及查看依赖校验。"""
codes = set(permission_codes)
if codes - CATALOG.keys() or len(codes) != len(permission_codes):
raise BadRequestError("权限未知或重复,请刷新目录")
if any(CATALOG[c]["admin_only"] for c in codes):
raise BadRequestError("管理员专用权限不能分配给普通角色")
if any(CATALOG[c]["view_code"] not in codes for c in codes):
raise BadRequestError("操作权限必须同时授予相应查看权限")
return codes
def save_role(session, role_id, payload, actor, meta=None):
audit_data = payload.model_dump(mode="json")
with audit_service.audit(session, operator=actor, meta=meta, record=AuditRecord(
"权限管理", "角色授权", f"修改角色 #{role_id} 的权限", audit_data
)):
policy = lock_policy(session, actor, payload.expected_revision)
role = session.get(Role, role_id)
if role is None:
raise NotFoundError("角色不存在")
if role.role_code == "admin":
raise ForbiddenError("管理员角色权限固定,不能修改")
codes = validate_role_permissions(payload.permission_codes)
audit_data["before"] = role_permissions(session, role.id)["permission_codes"]
audit_data["after"] = sorted(codes)
session.execute(delete(RolePermission).where(RolePermission.role_id == role.id))
ids = dict(session.execute(select(Permission.perm_code, Permission.id)).all())
session.add_all([RolePermission(role_id=role.id, permission_id=ids[c]) for c in codes])
bump(policy, actor)
session.flush()
result = role_permissions(session, role.id)
session.commit()
return result
def settings_out(session):
policy = auth.setting(session)
return {"mode": "roles", "revision": policy.revision, "maker_user_id": None,
"maker_username": None,
"maker_status": None, "updated_by": policy.updated_by,
"updated_at": policy.update_time}
def save_settings(session, payload, actor, meta=None):
raise BadRequestError("已取消单独的制作人绑定,请在角色管理配置权限,再在用户管理分配角色")