Files
hunjing/backend/app/plugins/service.py
T
chenxin 9ef2568c3b ✨ feat: WP-07 插件生态——Registry、三态分离与调用门(ADR-001/008)
- 迁移 14:plugin(kind 四形态、撤架三态)、plugin_version(Manifest JSONB + 规范化内容哈希签名、(plugin,version) 唯一)、plugin_installation(enabled + granted_permissions、(domain,plugin) 唯一)
- Registry:发布版本(Manifest 必须声明 permissions,签名=canonical 哈希);撤架阻断新调用与新安装,历史审计保留(验证:撤架后 invoked 审计仍在)
- 三态分离(购买≠安装≠启用≠授权):安装默认 disabled+零授权;启用必须显式授予权限子集(越界 422);调用门逐级校验(未装/未启/撤架各 403)
- 升级:Manifest 新增权限必须重新确认(未确认 422 且明示新增项);确认后升级成功
- 调用:按 Manifest response_template 真实执行(声明式确定性回填);remote/isolated 形态明示 501 随沙箱交付(WP-14),不静默降级;审计含版本哈希(快照语义)
- 权限标识统一 group:item 格式
- 测试 86 项全绿(+4):三态流、越界、升级确认、撤架阻断与历史保留
2026-09-12 10:18:20 +08:00

300 lines
11 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.
"""插件域:Registry、安装三态与调用门(WP-07 / ADR-001/008)。"""
import hashlib
import json
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import AuditEvent, Plugin, PluginInstallation, PluginVersion
def _hash_manifest(manifest: dict[str, Any]) -> str:
canonical = json.dumps(manifest, sort_keys=True, ensure_ascii=False)
return f"sha256:{hashlib.sha256(canonical.encode('utf-8')).hexdigest()}"
def _manifest_permissions(manifest: dict[str, Any]) -> set[str]:
"""权限标识统一为 group:item(如 network:api.example.com)。"""
perms = manifest.get("permissions", {})
result: set[str] = set()
if isinstance(perms, dict):
for group, items in perms.items():
if isinstance(items, list):
result.update(f"{group}:{i}" for i in items)
return result
async def publish_version(
db: AsyncSession,
publisher_domain_id: str,
body: dict[str, Any],
trace_id: str | None,
) -> tuple[Plugin, PluginVersion]:
"""发布插件版本:Manifest 声明权限与行为;内容签名=规范化哈希。
安装展示数据、网络、设备、费用、外部目的地与现实后果(ADR/契约)。
"""
name = str(body.get("name", "")).strip()
if not name or "." not in name:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "name 须为点分稳定名")
version = str(body.get("version", "")).strip()
if not version:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "version 不能为空")
manifest = body.get("manifest", {})
if not isinstance(manifest, dict) or "permissions" not in manifest:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "manifest 必须声明 permissions")
plugin = await db.get(Plugin, name)
if plugin is None:
plugin = Plugin(
name=name,
publisher_domain_id=publisher_domain_id,
kind=str(body.get("kind", "declarative")),
)
db.add(plugin)
await db.flush()
elif plugin.publisher_domain_id != publisher_domain_id:
raise HTTPException(status.HTTP_409_CONFLICT, "插件名已被其他发布者占用")
existing = await db.execute(
select(PluginVersion).where(
PluginVersion.plugin_name == name, PluginVersion.version == version
)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(status.HTTP_409_CONFLICT, "版本已存在")
pv = PluginVersion(
plugin_name=name,
version=version,
manifest=manifest,
content_hash=_hash_manifest(manifest),
)
db.add(pv)
await db.flush()
db.add(
AuditEvent(
actor_type="user", actor_id=publisher_domain_id, action="plugin.published",
aggregate_type="plugin", aggregate_id=name,
payload={"version": version, "hash": pv.content_hash}, trace_id=trace_id,
)
)
await db.flush()
return plugin, pv
async def withdraw(
db: AsyncSession, publisher_domain_id: str, name: str, trace_id: str | None
) -> Plugin:
"""安全撤架:阻断新调用,保留历史审计与安装记录(不静默删除)。"""
plugin = await db.get(Plugin, name)
if plugin is None or plugin.publisher_domain_id != publisher_domain_id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "插件不存在")
plugin.status = "removed"
await db.execute(
select(PluginVersion).where(PluginVersion.plugin_name == name) # 触发加载
)
for pv in (
await db.scalars(select(PluginVersion).where(PluginVersion.plugin_name == name))
).all():
pv.status = "removed"
db.add(
AuditEvent(
actor_type="user", actor_id=publisher_domain_id, action="plugin.withdrawn",
aggregate_type="plugin", aggregate_id=name, trace_id=trace_id,
)
)
await db.flush()
return plugin
async def install(
db: AsyncSession,
domain_id: str,
name: str,
version: str,
trace_id: str | None,
) -> PluginInstallation:
"""安装:默认 disabled、零授权——购买≠安装≠启用≠授权(WP-07 验证项)。"""
plugin = await db.get(Plugin, name)
if plugin is None or plugin.status != "published":
raise HTTPException(status.HTTP_404_NOT_FOUND, "插件不存在或已撤架")
existing_install = await db.execute(
select(PluginInstallation).where(
PluginInstallation.domain_id == domain_id,
PluginInstallation.plugin_name == name,
)
)
if existing_install.scalar_one_or_none() is not None:
raise HTTPException(status.HTTP_409_CONFLICT, "已安装(升级请走升级端点)")
pv = (
await db.execute(
select(PluginVersion).where(
PluginVersion.plugin_name == name,
PluginVersion.version == version,
PluginVersion.status == "published",
)
)
).scalar_one_or_none()
if pv is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "版本不存在或不可用")
installation = PluginInstallation(
domain_id=domain_id, plugin_name=name, version_id=pv.id,
enabled=False, granted_permissions=[],
)
db.add(installation)
await db.flush()
db.add(
AuditEvent(
actor_type="user", actor_id=domain_id, action="plugin.installed",
aggregate_type="plugin", aggregate_id=name,
payload={"version": version, "enabled": False}, trace_id=trace_id,
)
)
await db.flush()
return installation
async def require_installation(
db: AsyncSession, domain_id: str, installation_id: str
) -> PluginInstallation:
installation = await db.get(PluginInstallation, installation_id)
if installation is None or installation.domain_id != domain_id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "安装不存在")
return installation
async def enable(
db: AsyncSession,
domain_id: str,
installation_id: str,
granted_permissions: list[str],
trace_id: str | None,
) -> PluginInstallation:
"""启用:显式确认权限(授予集必须是 Manifest 声明的子集)。"""
installation = await require_installation(db, domain_id, installation_id)
pv = await db.get(PluginVersion, installation.version_id)
assert pv is not None
declared = _manifest_permissions(pv.manifest)
granted = set(granted_permissions)
if not granted.issubset(declared):
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
f"授权超出 Manifest 声明:{sorted(granted - declared)}",
)
installation.enabled = True
installation.granted_permissions = sorted(granted)
db.add(
AuditEvent(
actor_type="user", actor_id=domain_id, action="plugin.enabled",
aggregate_type="plugin", aggregate_id=installation.plugin_name,
payload={"granted": sorted(granted)}, trace_id=trace_id,
)
)
await db.flush()
return installation
async def upgrade(
db: AsyncSession,
domain_id: str,
installation_id: str,
version: str,
granted_permissions: list[str] | None,
trace_id: str | None,
) -> PluginInstallation:
"""升级:Manifest 新增权限必须重新确认(granted 覆盖新增项,否则 422)。"""
installation = await require_installation(db, domain_id, installation_id)
current = await db.get(PluginVersion, installation.version_id)
assert current is not None
target = (
await db.execute(
select(PluginVersion).where(
PluginVersion.plugin_name == installation.plugin_name,
PluginVersion.version == version,
PluginVersion.status == "published",
)
)
).scalar_one_or_none()
if target is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "目标版本不存在")
old_declared = _manifest_permissions(current.manifest)
new_declared = _manifest_permissions(target.manifest)
newly_added = new_declared - old_declared
granted = (
set(granted_permissions) if granted_permissions is not None
else set(installation.granted_permissions)
)
if newly_added and not newly_added.issubset(granted):
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
f"新版本新增权限需要重新确认:{sorted(newly_added - granted)}",
)
if not granted.issubset(new_declared):
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "授权超出 Manifest 声明")
installation.version_id = target.id
installation.granted_permissions = sorted(granted)
db.add(
AuditEvent(
actor_type="user", actor_id=domain_id, action="plugin.upgraded",
aggregate_type="plugin", aggregate_id=installation.plugin_name,
payload={"to_version": version, "newly_added": sorted(newly_added)},
trace_id=trace_id,
)
)
await db.flush()
return installation
async def invoke(
db: AsyncSession,
domain_id: str,
name: str,
input_: dict[str, Any],
run_id: str | None,
trace_id: str | None,
) -> dict[str, Any]:
"""调用门:撤架/未启用/未授权逐一校验;按 Manifest 声明真实执行。
声明式插件执行 response_template 回填(确定性、无副作用);
remote/isolated 形态的传输执行随 WP-14 沙箱交付,当前未安装即拒绝。
"""
plugin = await db.get(Plugin, name)
if plugin is None or plugin.status != "published":
raise HTTPException(status.HTTP_403_FORBIDDEN, "插件不可用(已撤架或不存在)")
installation = (
await db.execute(
select(PluginInstallation).where(
PluginInstallation.domain_id == domain_id,
PluginInstallation.plugin_name == name,
)
)
).scalar_one_or_none()
if installation is None:
raise HTTPException(status.HTTP_403_FORBIDDEN, "未安装")
if not installation.enabled:
raise HTTPException(status.HTTP_403_FORBIDDEN, "未启用")
pv = await db.get(PluginVersion, installation.version_id)
assert pv is not None
if pv.status != "published":
raise HTTPException(status.HTTP_403_FORBIDDEN, "已安装版本已撤架")
if plugin.kind != "declarative":
raise HTTPException(
status.HTTP_501_NOT_IMPLEMENTED, "该形态执行随沙箱 Runtime 交付(WP-14)"
)
template = str(pv.manifest.get("response_template", ""))
output = {"executed": True, "runtime": "declarative", "response": template}
db.add(
AuditEvent(
actor_type="user", actor_id=domain_id, action="plugin.invoked",
aggregate_type="plugin", aggregate_id=name,
payload={
"version": pv.version, "run_id": run_id,
"version_hash": pv.content_hash, # 快照语义:审计可追溯当时版本
},
trace_id=trace_id,
)
)
await db.flush()
return output