- 迁移 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):三态流、越界、升级确认、撤架阻断与历史保留
300 lines
11 KiB
Python
300 lines
11 KiB
Python
"""插件域: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
|