Files
AgentLab/backend/app/auth/directory.py
T
ThearchyHelios fbb671a96b 使用者选人(前端):选人控件按池边输入边取候选;个人渠道的实例事件改叫「我发起的实例完成 / 失败」
为什么:使用者读不到名录,选人控件只能凭记忆手填用户名,填错了服务端以前也照收(05-已知遗留 (b))。服务端已按用途给候选池、
开登录时按同一个池核对(上上个提交),前端要把候选给到人手上。另外个人渠道勾「实例完成 / 失败」以前永远收不到,服务端修好之后
(驳回那个提交里的 notify_run),名字要照实写成只推自己发起的实例(04-对外集成与触达 B1)。

做了什么:
- PrincipalPicker 加 source(CandidateSource:form 带 app_id+entry 或 task_id 和 field,review)。搭建者及以上照旧用整份名录;
  使用者开了登录、给了 source 时边输入边按开头取候选(250 毫秒防抖),列表里显示显示名、标签悬停给用户名;加的时候按同一个池
  问一次服务端,不在池里的当场说「「x」不在可选的人里」(问不到就先收下,提交时由服务端判);池要先输开头才列、列不全时照实提示。
- SchemaForm 按表单用在哪(入口、待办,同上传文件的上下文)给选人控件 source;个人应用提交审核用 purpose=review。
- typesApp:CandidateSource、Candidates、FormField 的 x-principal、FormError 的 principal 码(和服务端 FORM_ERROR_CODES 同序)。
- api.identity.candidates。termsApp 的 PRINCIPAL_TEXT 加三句。
- 设置 → 通知:范围是「仅自己」的渠道,run.finished / run.failed 写「我发起的实例完成 / 失败」(termsApp.NOTIFY_PERSONAL_EVENT_LABEL)。
- 服务端 x-principal 写法不对时的说法改成不带半角冒号接中文的写法(check-copy)。
2026-10-16 10:54:30 +08:00

274 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""人和组的名录:选人控件的候选(GET /api/directory)、应用检查核对岗位绑定和授权、改授权时核对填的名字,同一处取、
同一个口径(停用的人也在名录里,标 disabled:已经绑着他的岗位、授权要能认出「这个人停用了」,不是「没有这个人」)。
使用者选人(表单里的选人控件、个人应用提交审核)不拿整份名录,拿按用途算的候选池(candidates,GET
/api/directory/candidates):只有这件事能选的人,只给用户名和显示名,停用的不列;开了登录时提交的值按同一个池核对
(docs/design/next-round/05-已知遗留.md (b))。名录整份仍只给搭建者。
名录里对不上怎么算看登录方式(同前端 PrincipalPicker):
- 账号密码登录:人和组都在本系统里建,名录里没有就是填错了(或人被删了),停用的人处理不了待办——都算错;
- 单点登录:人和组第一次登录后才进名录,名录里没有的照实提醒、不拒收;停用的人名录里认得出,照样算错;
- 关登录:没有用户表,签批记的是各自填的署名,不核对。
"""
from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass, field
from typing import Any, Literal
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
#: 名录里对不上的两种:没有这个人或组、人停用了
Problem = Literal["missing", "disabled"]
_KIND = {"user": "用户", "group": "组"}
@dataclass(frozen=True)
class DirectoryUser:
username: str
display_name: str
disabled: bool
@dataclass(frozen=True)
class DirectoryGroup:
name: str
label: str
@dataclass
class Directory:
users: dict[str, DirectoryUser] = field(default_factory=dict)
groups: dict[str, DirectoryGroup] = field(default_factory=dict)
def problem(self, kind: str, name: str) -> Problem | None:
"""这一项在名录里对不上的地方;所有人(everyone)不判。"""
if kind == "group":
return None if name in self.groups else "missing"
if kind == "user":
user = self.users.get(name)
return "missing" if user is None else "disabled" if user.disabled else None
return None
async def display_names(session: AsyncSession, usernames: Iterable[str | None]) -> dict[str, str]:
"""用户名 → 显示名(名录里有的;没写显示名的就是用户名)。待办上派给谁、谁回复的、谁处理的给人看时写显示名,悬停给
用户名。关登录时没有用户表,查不到的照写署名(调用方 people.get(名字, 名字))。"""
from app.db.models import User
wanted = sorted({u for u in usernames if u})
if not wanted:
return {}
rows = (await session.execute(select(User.username, User.display_name).where(User.username.in_(wanted)))).all()
return {username: display or username for username, display in rows}
async def load_directory(session: AsyncSession) -> Directory:
from app.db.models import Group, User
users = (await session.execute(select(User).order_by(User.username))).scalars()
groups = (await session.execute(select(Group).order_by(Group.name))).scalars()
return Directory(
users={u.username: DirectoryUser(u.username, u.display_name or u.username, u.disabled) for u in users},
groups={g.name: DirectoryGroup(g.name, g.label or g.name) for g in groups})
@dataclass(frozen=True)
class Mismatch:
"""一项对不上:blocking 为真是错(账号密码登录名录里没有、任何登录方式下停用了),为假只提醒(单点登录名录里还没有)。
text 是「用户「x」已停用」这样的半句,调用方接上是哪个岗位、哪一档授权。"""
kind: str
name: str
problem: Problem
blocking: bool
text: str
def mismatch(directory: Directory, mode: str, kind: str, name: str) -> Mismatch | None:
"""按登录方式判这一项:关登录、所有人、对得上的都是 None。"""
if mode == "off":
return None
problem = directory.problem(kind, name)
if problem is None:
return None
what = f"{_KIND.get(kind, kind)}「{name}」"
if problem == "disabled":
return Mismatch(kind, name, problem, True, f"{what}已停用")
if mode == "oidc":
return Mismatch(kind, name, problem, False,
f"{what}名录里还没有:单点登录的人和组第一次登录后才进名录,名字要和身份提供方里的一致才生效")
return Mismatch(kind, name, problem, True, f"{what}名录里没有")
# --------------------------------------------------------------------------
# 候选池:使用者选人时能选谁(05-已知遗留 (b))
# --------------------------------------------------------------------------
#: 候选池的用途:form 表单里的选人控件(入口表单、待办表单);review 个人应用提交审核选审核的人
PURPOSES = ("form", "review")
#: 一次最多给几位候选
CANDIDATES_MAX = 50
#: 表单字段声明候选池(x-principal.pool):app 能用这个应用的人(缺省);group:<组> 这个组的成员;slot:<岗位> 这个岗位
#: 绑定的人和组(按应用版本的 bindings)
POOL_APP = "app"
POOL_GROUP = "group:"
POOL_SLOT = "slot:"
def pool_problem(value: Any) -> str | None:
"""x-principal 的写法对不对:只能是 {pool: app | group:<组> | slot:<岗位>}。没问题为 None。"""
if not isinstance(value, dict) or set(value) != {"pool"} or not isinstance(value.get("pool"), str):
return "的候选范围只能写成 {\"pool\": \"app\"}、{\"pool\": \"group:<组名>\"} 或 {\"pool\": \"slot:<岗位>\"}"
pool = value["pool"].strip()
if pool == POOL_APP:
return None
for prefix in (POOL_GROUP, POOL_SLOT):
if pool.startswith(prefix) and pool[len(prefix):].strip():
return None
return "的候选范围只能是 app(能用这个应用的人)、group:<组名> 或 slot:<岗位>"
def pool_of(field: Any) -> str:
"""表单字段声明的候选池;没声明、写法不对的按缺省(能用这个应用的人)。"""
setting = field.get("x-principal") if isinstance(field, dict) else None
if isinstance(setting, dict) and pool_problem(setting) is None:
return setting["pool"].strip()
return POOL_APP
@dataclass
class Pool:
"""一个候选池。open:池就是全部启用的人(应用授权给了所有人)——不带前缀不列,免得等于发通讯录。"""
users: dict[str, DirectoryUser] = field(default_factory=dict)
groups: dict[str, DirectoryGroup] = field(default_factory=dict)
open: bool = False
def has(self, name: str) -> bool:
"""提交的这个名字在不在池里(用户名或组名)。"""
return name in self.users or name in self.groups
def pick(self, q: str | None, limit: int = CANDIDATES_MAX) -> dict[str, Any]:
"""接口给的那一份:{users: [{username, display_name}], groups: [{name, label}], truncated};按前缀筛
(用户名或显示名,不分大小写)。池是 open 的、又没带前缀:什么都不给,needs_query 为真。"""
prefix = (q or "").strip().lower()
if self.open and not prefix:
return {"users": [], "groups": [], "truncated": False, "needs_query": True}
limit = max(1, min(int(limit or CANDIDATES_MAX), CANDIDATES_MAX))
def hit(*names: str) -> bool:
return not prefix or any(n.lower().startswith(prefix) for n in names if n)
users = [u for u in sorted(self.users.values(), key=lambda u: u.username) if hit(u.username, u.display_name)]
groups = [g for g in sorted(self.groups.values(), key=lambda g: g.name) if hit(g.name, g.label)]
return {"users": [{"username": u.username, "display_name": u.display_name} for u in users[:limit]],
"groups": [{"name": g.name, "label": g.label} for g in groups[:limit]],
"truncated": len(users) > limit or len(groups) > limit}
async def _members(session: AsyncSession, group: str) -> dict[str, DirectoryUser]:
from app.db.models import Group, GroupMember, User
rows = (await session.execute(
select(User).join(GroupMember, GroupMember.user_id == User.id).join(Group, Group.id == GroupMember.group_id)
.where(Group.name == group, User.disabled.is_(False)))).scalars()
return {u.username: DirectoryUser(u.username, u.display_name or u.username, False) for u in rows}
async def _people(session: AsyncSession, items: Iterable[tuple[str, str]]) -> Pool:
"""一组(种类, 名字)展开成池:人按名录认(停用的、名录里没有的不列),组列出组本身、再展开成员。"""
from app.db.models import Group, User
pool = Pool()
items = list(items)
names = sorted({n for k, n in items if k == "user" and n})
if names:
for u in (await session.execute(select(User).where(User.username.in_(names), User.disabled.is_(False))))\
.scalars():
pool.users[u.username] = DirectoryUser(u.username, u.display_name or u.username, False)
groups = sorted({n for k, n in items if k == "group" and n})
if groups:
for g in (await session.execute(select(Group).where(Group.name.in_(groups)))).scalars():
pool.groups[g.name] = DirectoryGroup(g.name, g.label or g.name)
pool.users.update(await _members(session, g.name))
return pool
async def _everyone(session: AsyncSession) -> Pool:
from app.db.models import Group, User
pool = Pool(open=True)
for u in (await session.execute(select(User).where(User.disabled.is_(False)))).scalars():
pool.users[u.username] = DirectoryUser(u.username, u.display_name or u.username, False)
for g in (await session.execute(select(Group))).scalars():
pool.groups[g.name] = DirectoryGroup(g.name, g.label or g.name)
return pool
async def app_pool(session: AsyncSession, app: Any) -> Pool:
"""能用这个应用的人:使用及以上的授权(人、组展开成员),加上管理它的人(管理员、建它的人)。授权给了所有人的,池就是
全部启用的人(open)。按 apps/access 的口径:搭建者没有授权时只能查看全部实例,不算「能用」的候选。"""
from app.apps.access import RANK, grants_of
from app.db.models import User
grants = [g for g in await grants_of(session, app.id) if RANK.get(g.role, 0) >= RANK["use"]]
if any(g.principal_kind == "everyone" for g in grants):
return await _everyone(session)
admins = (await session.execute(select(User.username).where(User.role == "admin"))).scalars()
items = [(g.principal_kind, g.principal) for g in grants] + [("user", a) for a in admins]
if getattr(app, "created_by", None):
items.append(("user", app.created_by))
return await _people(session, items)
async def field_pool(session: AsyncSession, app: Any, bindings: dict[str, Any] | None, field: Any) -> Pool:
"""表单里一个选人控件的候选池(按字段的 x-principal,缺省能用这个应用的人)。bindings 是这次用到的应用版本的绑定。"""
pool = pool_of(field)
if pool.startswith(POOL_GROUP):
name = pool[len(POOL_GROUP):].strip()
return Pool(users=await _members(session, name))
if pool.startswith(POOL_SLOT):
bound = ((bindings or {}).get("slots") or {}).get(pool[len(POOL_SLOT):].strip())
items = [(str(b.get("kind") or "user"), str(b.get("name") or "")) for b in bound or [] if isinstance(b, dict)]
if any(k == "everyone" for k, _ in items):
return await _everyone(session)
return await _people(session, items)
return await app_pool(session, app)
async def review_pool(session: AsyncSession) -> Pool:
"""个人应用提交审核能交给谁:启用的搭建者和管理员(同 blueprints/personal._review_targets 的规则)。"""
from app.db.models import User
rows = (await session.execute(select(User).where(User.role.in_(("builder", "admin")),
User.disabled.is_(False)))).scalars()
return Pool(users={u.username: DirectoryUser(u.username, u.display_name or u.username, False) for u in rows})
def principal_fields(form: Any) -> dict[str, dict[str, Any]]:
"""表单里的选人控件:{字段名: 字段}。"""
props = form.get("properties") if isinstance(form, dict) else None
return {name: f for name, f in (props or {}).items() if isinstance(f, dict) and f.get("x-widget") == "principal"}
#: 选人控件的值不在候选池里时表单错误的 code(apps/spec.FORM_ERROR_CODES 的 principal)
PRINCIPAL_ERROR_CODE = "principal"
async def principal_errors(session: AsyncSession, app: Any, bindings: dict[str, Any] | None, form: Any,
values: Any, mode: str) -> list[dict[str, str]]:
"""开了登录时按候选池核对表单里选人控件的值:不在池里的按表单错误交回(code=principal),话写成「「x」不在可选的人里」。
关登录不核对:没有用户表,签批记的是各自填的署名。没填的不在这里报(必填由表单校验报)。"""
if mode == "off" or not isinstance(values, dict):
return []
out: list[dict[str, str]] = []
for name, f in principal_fields(form).items():
value = values.get(name)
if value in (None, "") or not isinstance(value, str):
continue
pool = await field_pool(session, app, bindings, f)
if not pool.has(value.strip()):
hint = ":单点登录的人要先登录一次才进名录" if mode == "oidc" else ",请从候选中选择"
out.append({"path": name, "code": PRINCIPAL_ERROR_CODE, "message": f"「{value.strip()}」不在可选的人里{hint}"})
return out