mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 16:01:59 +00:00
19329c873e
外部系统(门户、OA、BI)和个人脚本要用一个身份调接口,以前只能拿个人会话 cookie 去模拟。这次加访问密钥:
- 签发(A1):users.kind 区分人和服务账号(svc- 开头、没有口令、不能登录、角色固定使用者,管理员建);access_keys 只存
sha256 和开头一截,明文 agl_ 加 32 字节随机串只显示一次;到期必填(最长 365 天、缺省 90 天)、可撤销、可轮换(新钥 + 旧钥
24 小时后到期);个人访问密钥由管理员开关 auth.personal_keys 管,缺省关,关掉时已有的个人密钥一并不认
- 认人与门禁(A2):Principal 加 via / key_id / key_name / scopes / app_ids(全部带默认值);中间件认 Bearer agl_…,不看
cookie、不查 CSRF、不看 X-Actor,认不出的各回 401 带各自的 code、绝不退回匿名;WebSocket 带密钥一律拒;关登录时 409
keys_need_login。KEY_ROUTES(方法 + 路由模板 → scope)一张表,FastAPI(dependencies=[Depends(key_gate)]) 挂一次;不在表里的
403 key_route_forbidden,缺 scope、限定应用、个人应用和试运行各有各的 403;每把密钥每分钟 120 次(429 + Retry-After)
- 封顶(A3):按密钥认出来的一律是使用者(拥有者现在的组),现有的使用者规则和遮罩全部自动生效,管理员的密钥也一样
- 写操作(A4):发起入口、刷新视图必须带 Idempotency-Key,api_receipts 按(拥有者, 键)去重,轮换后照样认得出;状态机从
process/hooks.py 抽到 process/receipts.py,钩子和密钥共用(钩子现有测试原样通过),另按请求指纹拒掉同一个键办另一件事;
有文件控件的入口 422;回复待办只给个人密钥、只限审批 / 补充输入 / 编辑稿件;run.origin、task.responded 记 via=key、哪把密钥
- 视图数据(A5):GET /api/apps/{id}/views/{view}/data(agentlab.viewdata/1),印章、清单哈希、口径卡指标原值和渲染文字,
遮罩的 value 为 null(和页面同一个判断),按需视图只读缓存、不发起运行,只给正式运行
- 留痕(A6):建 / 撤销 / 轮换密钥、建 / 停用服务账号、开关个人密钥写审计;请求记录进程内缓冲、调度循环一拍里批量写、
保留 90 天;last_used_at 至少隔 1 分钟写一次;钩子收件记录接口(manage),验签失败只在进程内按钩子计数
- 名录与派单:服务账号在名录里标出,岗位绑定、选人候选、提交审核、改派里排除,应用授权照收;服务账号发起的实例里派给发起人
的待办交给任何人
- 接口说明(A7):GET /api/access-keys/reference 从 KEY_ROUTES 生成(要登录)
测试:签发与各种 401、门禁合约(遍历全站路由)、CSRF 与身份来源、封顶(管理员密钥取工件也是遮过的)、幂等(含轮换后、
半路杀进程)、留痕、待办种类、视图数据(真跑的遮罩会员库)、限流、钩子收件记录。
299 lines
15 KiB
Python
299 lines
15 KiB
Python
"""人和组的名录:选人控件的候选(GET /api/directory)、应用检查核对岗位绑定和授权、改授权时核对填的名字,同一处取、
|
||
同一个口径(停用的人也在名录里,标 disabled:已经绑着他的岗位、授权要能认出「这个人停用了」,不是「没有这个人」)。
|
||
|
||
使用者选人(表单里的选人控件、个人应用提交审核)不拿整份名录,拿按用途算的候选池(candidates,GET
|
||
/api/directory/candidates):只有这件事能选的人,只给用户名和显示名,停用的不列;开了登录时提交的值按同一个池核对
|
||
(docs/design/next-round/05-已知遗留.md (b))。名录整份仍只给搭建者。
|
||
|
||
名录里对不上怎么算看登录方式(同前端 PrincipalPicker):
|
||
- 账号密码登录:人和组都在本系统里建,名录里没有就是填错了(或人被删了),停用的人处理不了待办——都算错;
|
||
- 单点登录:人和组第一次登录后才进名录,名录里没有的照实提醒、不拒收;停用的人名录里认得出,照样算错;
|
||
- 关登录:没有用户表,签批记的是各自填的署名,不核对。
|
||
|
||
服务账号(users.kind=service,docs/design/next-round/04-对外集成与触达.md A1)也在名录里、标 service:它能拿应用授权(外部系统
|
||
按授权读应用),但不能被派待办——它只持有访问密钥,密钥回复不了待办。所以岗位绑定、个人应用提交审核核对时它算错
|
||
(mismatch 的 allow_service 为假时),候选池(表单选人、提交审核)里一律不列;授权核对放过它(allow_service=True)。
|
||
"""
|
||
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", "service"]
|
||
#: users.kind 里服务账号的那个值(同 auth/keys.SERVICE_KIND)
|
||
SERVICE_KIND = "service"
|
||
_KIND = {"user": "用户", "group": "组"}
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class DirectoryUser:
|
||
username: str
|
||
display_name: str
|
||
disabled: bool
|
||
#: 服务账号(只持有访问密钥、不能被派待办)
|
||
service: bool = False
|
||
|
||
|
||
@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, *, allow_service: bool = False) -> Problem | None:
|
||
"""这一项在名录里对不上的地方;所有人(everyone)不判。服务账号只在 allow_service 时算对得上(应用授权)。"""
|
||
if kind == "group":
|
||
return None if name in self.groups else "missing"
|
||
if kind == "user":
|
||
user = self.users.get(name)
|
||
if user is None:
|
||
return "missing"
|
||
if user.disabled:
|
||
return "disabled"
|
||
return "service" if user.service and not allow_service 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,
|
||
u.kind == SERVICE_KIND) 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, *, allow_service: bool = False) -> Mismatch | None:
|
||
"""按登录方式判这一项:关登录、所有人、对得上的都是 None。allow_service:服务账号算不算对得上(应用授权算;岗位绑定、
|
||
提交审核这些要派待办的不算)。"""
|
||
if mode == "off":
|
||
return None
|
||
problem = directory.problem(kind, name, allow_service=allow_service)
|
||
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 problem == "service":
|
||
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
|
||
|
||
|
||
def _person() -> Any:
|
||
"""SQL 条件:是人,不是服务账号(kind 为空的老行按人算)。候选池里一律不列服务账号。"""
|
||
from sqlalchemy import func
|
||
|
||
from app.db.models import User
|
||
|
||
return func.coalesce(User.kind, "person") != SERVICE_KIND
|
||
|
||
|
||
@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), _person()))).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),
|
||
_person()))).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), _person()))).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), _person()))).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
|