✨ 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):三态流、越界、升级确认、撤架阻断与历史保留
This commit is contained in:
chenxin committed 2026-09-12 10:18:20 +08:00
1 parent 795df0413e
commit 9ef2568c3b
7 files changed
+771

No files matched your search

+299
View File
@@ -0,0 +1,299 @@
"""插件域: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