Files
AgentLab/backend/app/api/identity.py
T
ThearchyHelios 941723b17c 端到端验收修复与指标出具:
- 身份与工具:登录页建第一个管理员(认人接口的 401 说明能不能建);搭建者有只读的人和组名录,选人给候选并核对名字;授权三行带标题;端点请求头名校验(空名、非法名存不进、发不出时说清原因);写入能力在工具页试运行一律演练、不发送,HTTP 状态照实说成败;写记忆不算只读、查询能力只能选同一端点上的只读能力;审批与遮罩的说法和实际规则一致;开登录时不要署名、导入和发布记登录的人;使用者不显示推演等只给搭建者的操作,403 按是否开登录说话;登录页只认一次人
- 流程与实例:图里用到的岗位(派给谁、超时升级给谁、发送确认派给谁)全部派生、没绑人的发布时报错;写外部系统的参数按能力声明的类型收拢后再算哈希和发送,收不成就不发;发起人的实例页只看这一步的状态,处理人才给待办卡;会签进度带登录核实和时间,升级后照实写规则;实例状态写流程的结论(已退回、未确认、等待核实写入结果),核实生效后不再挂失败原因;流程应用首页不派生最近封存运行的结果块,看板没数据时是安静的空态;样例⑩的金额带类型和下限、待办标题带金额,表单上下限前后端一起生效
- 看板与证据:定时任务模板加 ISO 周、上一 ISO 周、本月、上月;出处链穿过原样解析查询结果的整形节点,追不到原始数据的照实说停在哪里,样例⑨改用 cell();应用首页和版本历史都能做成应用;指标块单位不重复;版本列表带退役说明;定时任务页头跟着刷新;删视图要确认、新视图能起名;编辑器属性用中文名、术语带解释
- 指标出具(用户 2026-10-15 拍板):契约只写「指标来自」、上游没有报告撰写节点、成果里没有模型写的话时按指标出具——必需指标齐、指标输入追到原始数据、成果字段里的数回指得上才完整出具;受管级要求每个指标静态地追到原始数据(governed.metric_untraced);出具横幅和应用印章写明按指标
- 受管工作流降为已发布只给管理员(Q8 顺延,服务端同样拦);工作流接口带当前发布版的级别,画布上改过之后也认得出受管工作流
2026-10-15 07:15:37 +08:00

240 lines
11 KiB
Python

"""用户、组、审计(BE-1,只给管理员:main.py 挂 require_role("admin"));人和组的名录(只读,给搭建者及以上)。
设计文档 §2.7、§3.10。
- 用户名一经建立就不改(各「人」列开了登录就写它);改叫法改显示名。单点登录来的用户角色每次登录按映射重算,这里改了
下次登录会被映射盖掉;
- 至少保留一个能用的管理员:降级、停用最后一个时拒绝;
- 从单点登录同步来的组只读(成员由每次登录的 groups 声明决定);
- 每个改动写审计(audit_events),和改动同一个事务。
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.audit import record
from app.auth.passwords import hash_password, password_problem
from app.auth.sessions import revoke_user
from app.db.base import get_session
from app.db.models import AuditEvent, Group, GroupMember, User
from app.db.models_identity import ROLES
router = APIRouter(prefix="/api", tags=["identity"])
#: 人和组的名录(GET /api/directory):只读、给搭建者及以上(main.py 挂 require_role("builder")),岗位绑定、应用授权的
#: 选人控件据此给候选、核对填的名字在不在。只有用户名、显示名、是否停用和组名,不含邮箱、角色、登录时间
directory_router = APIRouter(prefix="/api", tags=["identity"])
def _iso(value: Any) -> str | None:
return value.isoformat() if value else None
def user_out(row: User) -> dict[str, Any]:
return {"id": row.id, "username": row.username, "display_name": row.display_name or row.username,
"email": row.email, "role": row.role, "disabled": row.disabled,
"source": "oidc" if row.oidc_subject else "local", "last_login_at": _iso(row.last_login_at),
"created_at": _iso(row.created_at)}
@router.get("/users")
async def list_users(session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
rows = (await session.execute(select(User).order_by(User.created_at))).scalars()
return {"items": [user_out(r) for r in rows]}
class UserIn(BaseModel):
username: str = Field(min_length=1, max_length=100, pattern=r"^[^\s/]+$")
display_name: str | None = Field(default=None, max_length=100)
email: str | None = Field(default=None, max_length=200)
role: str = "user"
password: str | None = Field(default=None, max_length=200)
def _check_role(role: str) -> None:
if role not in ROLES:
raise HTTPException(422, "角色只能是管理员、搭建者、使用者之一")
@router.post("/users", status_code=201)
async def create_user(payload: UserIn, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
_check_role(payload.role)
if payload.password and (problem := password_problem(payload.password)):
raise HTTPException(422, problem)
if (await session.execute(select(User.id).where(User.username == payload.username))).first():
raise HTTPException(409, f"用户名「{payload.username}」已存在")
from app.api.deps import current_principal
row = User(username=payload.username, display_name=payload.display_name or payload.username,
email=payload.email or None, role=payload.role,
password_hash=hash_password(payload.password) if payload.password else None,
created_by=current_principal().username)
session.add(row)
await session.flush()
await record(session, "user.create", "user", row.id, data={"username": row.username, "role": row.role})
await session.commit()
return user_out(row)
class UserPatch(BaseModel):
display_name: str | None = Field(default=None, max_length=100)
email: str | None = Field(default=None, max_length=200)
role: str | None = None
disabled: bool | None = None
password: str | None = Field(default=None, max_length=200)
async def _other_admins(session: AsyncSession, user_id: str) -> int:
return int((await session.execute(select(func.count()).select_from(User).where(
User.role == "admin", User.disabled.is_(False), User.id != user_id))).scalar_one())
@router.patch("/users/{user_id}")
async def update_user(user_id: str, payload: UserPatch, session: AsyncSession = Depends(get_session)
) -> dict[str, Any]:
row = await session.get(User, user_id)
if row is None:
raise HTTPException(404, "用户不存在")
changes: dict[str, Any] = {}
if payload.role is not None and payload.role != row.role:
_check_role(payload.role)
changes["role"] = [row.role, payload.role]
if payload.disabled is not None and payload.disabled != row.disabled:
changes["disabled"] = [row.disabled, payload.disabled]
losing_admin = row.role == "admin" and not row.disabled and (
("role" in changes and payload.role != "admin") or payload.disabled is True)
if losing_admin and not await _other_admins(session, row.id):
raise HTTPException(409, "至少需要保留一个可用的管理员")
if payload.password:
if problem := password_problem(payload.password):
raise HTTPException(422, problem)
row.password_hash = hash_password(payload.password)
changes["password"] = "reset"
await revoke_user(session, row.id)
if payload.display_name is not None:
row.display_name = payload.display_name
if payload.email is not None:
row.email = payload.email or None
if "role" in changes:
row.role = payload.role or row.role
if "disabled" in changes:
row.disabled = bool(payload.disabled)
if row.disabled:
await revoke_user(session, row.id)
if changes:
await record(session, "user.update", "user", row.id, data={"username": row.username, **changes})
await session.commit()
return user_out(row)
# ---------------------------------------------------------------- 组
async def _group_out(session: AsyncSession, row: Group) -> dict[str, Any]:
members = (await session.execute(select(User.username).join(GroupMember, GroupMember.user_id == User.id)
.where(GroupMember.group_id == row.id).order_by(User.username))).scalars()
return {"id": row.id, "name": row.name, "label": row.label or row.name, "source": row.source,
"members": list(members)}
@router.get("/groups")
async def list_groups(session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
rows = list((await session.execute(select(Group).order_by(Group.name))).scalars())
return {"items": [await _group_out(session, r) for r in rows]}
class GroupIn(BaseModel):
name: str = Field(min_length=1, max_length=100, pattern=r"^[^\s/]+$")
label: str | None = Field(default=None, max_length=100)
@router.post("/groups", status_code=201)
async def create_group(payload: GroupIn, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
if (await session.execute(select(Group.id).where(Group.name == payload.name))).first():
raise HTTPException(409, f"组「{payload.name}」已存在")
row = Group(name=payload.name, label=payload.label or payload.name, source="local")
session.add(row)
await session.flush()
await record(session, "group.create", "group", row.id, data={"name": row.name})
await session.commit()
return await _group_out(session, row)
async def _local_group(session: AsyncSession, group_id: str) -> Group:
row = await session.get(Group, group_id)
if row is None:
raise HTTPException(404, "组不存在")
if row.source != "local":
raise HTTPException(409, "这个组从单点登录同步而来,成员由身份提供方决定,不能在这里修改")
return row
class MembersIn(BaseModel):
members: list[str] = Field(default_factory=list)
@router.put("/groups/{group_id}/members")
async def set_members(group_id: str, payload: MembersIn, session: AsyncSession = Depends(get_session)
) -> dict[str, Any]:
"""整组成员换成给的这些用户名。人员变动只改组成员,不发新的应用版本(写审计)。"""
from app.api.deps import current_principal
row = await _local_group(session, group_id)
wanted = list(dict.fromkeys(m.strip() for m in payload.members if m.strip()))
users = {u.username: u for u in (await session.execute(select(User).where(User.username.in_(wanted)))).scalars()}
if missing := [m for m in wanted if m not in users]:
raise HTTPException(422, f"这些用户不存在:{'、'.join(missing)}")
current = {m.user_id: m for m in (await session.execute(
select(GroupMember).where(GroupMember.group_id == row.id))).scalars()}
keep = {users[m].id for m in wanted}
before = (await _group_out(session, row))["members"]
for user_id, member in current.items():
if user_id not in keep:
await session.delete(member)
for user_id in keep - set(current):
session.add(GroupMember(group_id=row.id, user_id=user_id, added_by=current_principal().username))
await record(session, "group.members", "group", row.id, data={"name": row.name, "before": before, "after": wanted})
await session.commit()
return await _group_out(session, row)
@router.delete("/groups/{group_id}", status_code=204)
async def delete_group(group_id: str, session: AsyncSession = Depends(get_session)) -> None:
row = await _local_group(session, group_id)
await record(session, "group.delete", "group", row.id, data={"name": row.name})
await session.delete(row)
await session.commit()
# ---------------------------------------------------------------- 名录
@directory_router.get("/directory")
async def directory(session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""选人用的名录。停用的人也列出来(标 disabled):已经绑着他的岗位、授权要能认出「这个人停用了」,不是「没有这个人」。"""
users = (await session.execute(select(User).order_by(User.username))).scalars()
groups = (await session.execute(select(Group).order_by(Group.name))).scalars()
return {"users": [{"username": u.username, "display_name": u.display_name or u.username, "disabled": u.disabled}
for u in users],
"groups": [{"name": g.name, "label": g.label or g.name} for g in groups]}
# ---------------------------------------------------------------- 审计
@router.get("/audit")
async def list_audit(subject_kind: str | None = None, subject_id: str | None = None, limit: int = 200,
session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
query = select(AuditEvent).order_by(AuditEvent.at.desc()).limit(max(1, min(limit, 1000)))
if subject_kind:
query = query.where(AuditEvent.subject_kind == subject_kind)
if subject_id:
query = query.where(AuditEvent.subject_id == subject_id)
rows = (await session.execute(query)).scalars()
return {"items": [{"id": r.id, "at": _iso(r.at), "actor": r.actor, "verified": r.verified, "action": r.action,
"subject_kind": r.subject_kind, "subject_id": r.subject_id, "data": r.data or {}}
for r in rows]}