✨ 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:
1 parent
795df0413e
commit
9ef2568c3b
7 files changed
+771
No files matched your search
@@ -0,0 +1,82 @@
|
||||
"""插件生态:Registry、版本与安装
|
||||
|
||||
Revision ID: f7bf2b464f1f
|
||||
Revises: bd36c0a63816
|
||||
Create Date: 2026-09-12 10:15:58.892690
|
||||
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'f7bf2b464f1f'
|
||||
down_revision: str | Sequence[str] | None = 'bd36c0a63816'
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('plugin',
|
||||
sa.Column('name', sa.String(), nullable=False),
|
||||
sa.Column('publisher_domain_id', sa.String(), nullable=False),
|
||||
sa.Column('kind', sa.String(), nullable=False),
|
||||
sa.Column('status', sa.String(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.CheckConstraint("kind IN ('declarative', 'remote', 'isolated_process', 'container')", name=op.f('ck_plugin_kind_values')),
|
||||
sa.CheckConstraint("status IN ('published', 'suspended', 'removed')", name=op.f('ck_plugin_status_values')),
|
||||
sa.ForeignKeyConstraint(['publisher_domain_id'], ['personal_domain.id'], name=op.f('fk_plugin_personal_domain'), ondelete='cascade'),
|
||||
sa.PrimaryKeyConstraint('name', name=op.f('pk_plugin'))
|
||||
)
|
||||
op.create_index(op.f('ix_plugin_publisher_domain_id'), 'plugin', ['publisher_domain_id'], unique=False)
|
||||
op.create_table('plugin_version',
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('plugin_name', sa.String(), nullable=False),
|
||||
sa.Column('version', sa.String(), nullable=False),
|
||||
sa.Column('manifest', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column('content_hash', sa.String(), nullable=False),
|
||||
sa.Column('status', sa.String(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.CheckConstraint("status IN ('published', 'suspended', 'removed')", name=op.f('ck_plugin_version_status_values')),
|
||||
sa.ForeignKeyConstraint(['plugin_name'], ['plugin.name'], name=op.f('fk_plugin_version_plugin'), ondelete='cascade'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_plugin_version'))
|
||||
)
|
||||
op.create_index(op.f('ix_plugin_version_plugin_name'), 'plugin_version', ['plugin_name'], unique=False)
|
||||
op.create_index('uq_plugin_version', 'plugin_version', ['plugin_name', 'version'], unique=True)
|
||||
op.create_table('plugin_installation',
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('domain_id', sa.String(), nullable=False),
|
||||
sa.Column('plugin_name', sa.String(), nullable=False),
|
||||
sa.Column('version_id', sa.String(), nullable=False),
|
||||
sa.Column('enabled', sa.Boolean(), nullable=False),
|
||||
sa.Column('granted_permissions', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['domain_id'], ['personal_domain.id'], name=op.f('fk_plugin_installation_personal_domain'), ondelete='cascade'),
|
||||
sa.ForeignKeyConstraint(['plugin_name'], ['plugin.name'], name=op.f('fk_plugin_installation_plugin'), ondelete='cascade'),
|
||||
sa.ForeignKeyConstraint(['version_id'], ['plugin_version.id'], name=op.f('fk_plugin_installation_plugin_version'), ondelete='restrict'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_plugin_installation'))
|
||||
)
|
||||
op.create_index(op.f('ix_plugin_installation_domain_id'), 'plugin_installation', ['domain_id'], unique=False)
|
||||
op.create_index('uq_plugin_installation', 'plugin_installation', ['domain_id', 'plugin_name'], unique=True)
|
||||
op.create_foreign_key(op.f('fk_project_project_version'), 'project', 'project_version', ['current_version_id'], ['id'], ondelete='set null', use_alter=True)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_constraint(op.f('fk_project_project_version'), 'project', type_='foreignkey')
|
||||
op.drop_index('uq_plugin_installation', table_name='plugin_installation')
|
||||
op.drop_index(op.f('ix_plugin_installation_domain_id'), table_name='plugin_installation')
|
||||
op.drop_table('plugin_installation')
|
||||
op.drop_index('uq_plugin_version', table_name='plugin_version')
|
||||
op.drop_index(op.f('ix_plugin_version_plugin_name'), table_name='plugin_version')
|
||||
op.drop_table('plugin_version')
|
||||
op.drop_index(op.f('ix_plugin_publisher_domain_id'), table_name='plugin')
|
||||
op.drop_table('plugin')
|
||||
# ### end Alembic commands ###
|
||||
@@ -15,6 +15,8 @@ from app.marketplace.routes import finance_router, webhook_router
|
||||
from app.marketplace.routes import router as marketplace_router
|
||||
from app.memories.routes import cycle_router
|
||||
from app.memories.routes import router as memories_router
|
||||
from app.plugins.routes import inst_router, invoke_router
|
||||
from app.plugins.routes import router as plugins_router
|
||||
from app.tools.routes import calls_router, tools_router
|
||||
from app.workspaces.routes import router as workspaces_router
|
||||
|
||||
@@ -46,6 +48,9 @@ def create_app() -> FastAPI:
|
||||
app.include_router(webhook_router)
|
||||
app.include_router(community_router)
|
||||
app.include_router(workspaces_router)
|
||||
app.include_router(plugins_router)
|
||||
app.include_router(inst_router)
|
||||
app.include_router(invoke_router)
|
||||
|
||||
@app.get("/healthz", tags=["ops"])
|
||||
async def healthz() -> dict[str, str]:
|
||||
|
||||
@@ -1091,3 +1091,84 @@ class ProjectVersion(Base):
|
||||
),
|
||||
Index("uq_project_version_number", "project_id", "version", unique=True),
|
||||
)
|
||||
|
||||
|
||||
class Plugin(Base):
|
||||
"""插件(Registry 条目,WP-07):撤架阻断新调用,历史审计保留。"""
|
||||
|
||||
__tablename__ = "plugin"
|
||||
|
||||
name: Mapped[str] = mapped_column(String, primary_key=True) # 点分稳定名
|
||||
publisher_domain_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("personal_domain.id", ondelete="cascade"), nullable=False, index=True
|
||||
)
|
||||
kind: Mapped[str] = mapped_column(String, nullable=False, default="declarative")
|
||||
status: Mapped[str] = mapped_column(String, nullable=False, default="published")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"kind IN ('declarative', 'remote', 'isolated_process', 'container')",
|
||||
name="kind_values",
|
||||
),
|
||||
CheckConstraint(
|
||||
"status IN ('published', 'suspended', 'removed')", name="status_values"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class PluginVersion(Base):
|
||||
"""插件版本:Manifest(权限/网络/数据声明)与内容签名(哈希)。"""
|
||||
|
||||
__tablename__ = "plugin_version"
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String, primary_key=True, default=lambda: new_id("plugin_version_")
|
||||
)
|
||||
plugin_name: Mapped[str] = mapped_column(
|
||||
ForeignKey("plugin.name", ondelete="cascade"), nullable=False, index=True
|
||||
)
|
||||
version: Mapped[str] = mapped_column(String, nullable=False)
|
||||
manifest: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
|
||||
content_hash: Mapped[str] = mapped_column(String, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String, nullable=False, default="published")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"status IN ('published', 'suspended', 'removed')", name="status_values"
|
||||
),
|
||||
Index("uq_plugin_version", "plugin_name", "version", unique=True),
|
||||
)
|
||||
|
||||
|
||||
class PluginInstallation(Base):
|
||||
"""安装:购买≠安装≠启用≠授权;启用必须显式确认权限子集。"""
|
||||
|
||||
__tablename__ = "plugin_installation"
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String, primary_key=True, default=lambda: new_id("plugin_installation_")
|
||||
)
|
||||
domain_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("personal_domain.id", ondelete="cascade"), nullable=False, index=True
|
||||
)
|
||||
plugin_name: Mapped[str] = mapped_column(
|
||||
ForeignKey("plugin.name", ondelete="cascade"), nullable=False
|
||||
)
|
||||
version_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("plugin_version.id", ondelete="restrict"), nullable=False
|
||||
)
|
||||
enabled: Mapped[bool] = mapped_column(nullable=False, default=False)
|
||||
granted_permissions: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("uq_plugin_installation", "domain_id", "plugin_name", unique=True),
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""插件域:Registry、安装三态与调用门。"""
|
||||
@@ -0,0 +1,146 @@
|
||||
"""插件域路由(WP-07)。"""
|
||||
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.auth.dependencies import get_current_user, require_state_change
|
||||
from app.conversations.routes import _domain_of
|
||||
from app.db.session import DbSession
|
||||
from app.models import Plugin, PluginVersion, User
|
||||
from app.plugins import service
|
||||
|
||||
router = APIRouter(prefix="/v1/plugins", tags=["plugins"])
|
||||
|
||||
|
||||
def _plugin_out(p: Plugin) -> dict[str, Any]:
|
||||
return {
|
||||
"name": p.name, "kind": p.kind, "status": p.status,
|
||||
"publisher_domain_id": p.publisher_domain_id,
|
||||
}
|
||||
|
||||
|
||||
def _version_out(v: PluginVersion) -> dict[str, Any]:
|
||||
return {
|
||||
"id": v.id, "plugin_name": v.plugin_name, "version": v.version,
|
||||
"manifest": v.manifest, "content_hash": v.content_hash, "status": v.status,
|
||||
}
|
||||
|
||||
|
||||
def _installation_out(i: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"id": i.id, "plugin_name": i.plugin_name, "version_id": i.version_id,
|
||||
"enabled": i.enabled, "granted_permissions": i.granted_permissions,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/versions", status_code=201)
|
||||
async def publish(
|
||||
db: DbSession,
|
||||
user: Annotated[User, Depends(require_state_change)],
|
||||
request: Request,
|
||||
body: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
domain = await _domain_of(db, user)
|
||||
_, pv = await service.publish_version(
|
||||
db, domain.id, body, getattr(request.state, "trace_id", None)
|
||||
)
|
||||
return _version_out(pv)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_plugins(
|
||||
db: DbSession, user: Annotated[User, Depends(get_current_user)]
|
||||
) -> dict[str, Any]:
|
||||
rows = list(
|
||||
(
|
||||
await db.scalars(
|
||||
select(Plugin).where(Plugin.status == "published").limit(50)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
return {"data": [_plugin_out(p) for p in rows]}
|
||||
|
||||
|
||||
@router.post("/{name}/withdrawals")
|
||||
async def withdraw(
|
||||
name: str,
|
||||
db: DbSession,
|
||||
user: Annotated[User, Depends(require_state_change)],
|
||||
request: Request,
|
||||
) -> dict[str, Any]:
|
||||
domain = await _domain_of(db, user)
|
||||
plugin = await service.withdraw(
|
||||
db, domain.id, name, getattr(request.state, "trace_id", None)
|
||||
)
|
||||
return _plugin_out(plugin)
|
||||
|
||||
|
||||
@router.post("/{name}/installations", status_code=201)
|
||||
async def install(
|
||||
name: str,
|
||||
db: DbSession,
|
||||
user: Annotated[User, Depends(require_state_change)],
|
||||
request: Request,
|
||||
body: dict[str, str],
|
||||
) -> dict[str, Any]:
|
||||
domain = await _domain_of(db, user)
|
||||
installation = await service.install(
|
||||
db, domain.id, name, body.get("version", ""),
|
||||
getattr(request.state, "trace_id", None),
|
||||
)
|
||||
return _installation_out(installation)
|
||||
|
||||
|
||||
inst_router = APIRouter(prefix="/v1/plugin-installations", tags=["plugins"])
|
||||
|
||||
|
||||
@inst_router.post("/{installation_id}/enablings", status_code=201)
|
||||
async def enable(
|
||||
installation_id: str,
|
||||
db: DbSession,
|
||||
user: Annotated[User, Depends(require_state_change)],
|
||||
request: Request,
|
||||
body: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
domain = await _domain_of(db, user)
|
||||
installation = await service.enable(
|
||||
db, domain.id, installation_id, body.get("granted_permissions", []),
|
||||
getattr(request.state, "trace_id", None),
|
||||
)
|
||||
return _installation_out(installation)
|
||||
|
||||
|
||||
@inst_router.post("/{installation_id}/upgrades", status_code=201)
|
||||
async def upgrade(
|
||||
installation_id: str,
|
||||
db: DbSession,
|
||||
user: Annotated[User, Depends(require_state_change)],
|
||||
request: Request,
|
||||
body: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
domain = await _domain_of(db, user)
|
||||
installation = await service.upgrade(
|
||||
db, domain.id, installation_id, body.get("version", ""),
|
||||
body.get("granted_permissions"), getattr(request.state, "trace_id", None),
|
||||
)
|
||||
return _installation_out(installation)
|
||||
|
||||
|
||||
invoke_router = APIRouter(prefix="/v1/plugins", tags=["plugins"])
|
||||
|
||||
|
||||
@invoke_router.post("/{name}/invocations")
|
||||
async def invoke(
|
||||
name: str,
|
||||
db: DbSession,
|
||||
user: Annotated[User, Depends(require_state_change)],
|
||||
request: Request,
|
||||
body: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
domain = await _domain_of(db, user)
|
||||
return await service.invoke(
|
||||
db, domain.id, name, body.get("input", {}), body.get("run_id"),
|
||||
getattr(request.state, "trace_id", None),
|
||||
)
|
||||
@@ -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
|
||||
@@ -1437,3 +1437,160 @@ class TestWorkspaces:
|
||||
# 被移除后访问失效
|
||||
after = await client.get(f"/v1/workspaces/{ws_id}/members", headers=editor)
|
||||
assert after.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("client")
|
||||
class TestPlugins:
|
||||
MANIFEST_V1 = {
|
||||
"permissions": {"network": ["api.example.com"], "data": ["public_web"]},
|
||||
"response_template": "已按声明式规则处理",
|
||||
}
|
||||
MANIFEST_V2 = {
|
||||
"permissions": {
|
||||
"network": ["api.example.com"],
|
||||
"data": ["public_web", "local_files"], # 新增权限
|
||||
},
|
||||
"response_template": "v2 处理",
|
||||
}
|
||||
|
||||
async def _publish(self, client, headers, version, manifest) -> None:
|
||||
resp = await client.post(
|
||||
"/v1/plugins/versions",
|
||||
json={"name": "example.fetch", "kind": "declarative",
|
||||
"version": version, "manifest": manifest},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
|
||||
async def test_three_state_separation(self, client) -> None:
|
||||
"""购买≠安装≠启用≠授权:安装默认禁用、零授权;启用显式授权。"""
|
||||
publisher = await login(client, "pl-pub@example.com")
|
||||
await self._publish(client, publisher, "1.0.0", self.MANIFEST_V1)
|
||||
user = await login(client, "pl-user@example.com")
|
||||
# 未安装直接调用:403
|
||||
before = await client.post(
|
||||
"/v1/plugins/example.fetch/invocations", json={"input": {}}, headers=user
|
||||
)
|
||||
assert before.status_code == 403
|
||||
# 安装:默认 disabled
|
||||
installation = await client.post(
|
||||
"/v1/plugins/example.fetch/installations",
|
||||
json={"version": "1.0.0"}, headers=user,
|
||||
)
|
||||
assert installation.status_code == 201
|
||||
assert installation.json()["enabled"] is False
|
||||
assert installation.json()["granted_permissions"] == []
|
||||
# 已安装未启用:调用仍拒
|
||||
disabled = await client.post(
|
||||
"/v1/plugins/example.fetch/invocations", json={"input": {}}, headers=user
|
||||
)
|
||||
assert disabled.status_code == 403
|
||||
# 启用:显式授权子集
|
||||
enabled = await client.post(
|
||||
f"/v1/plugin-installations/{installation.json()['id']}/enablings",
|
||||
json={"granted_permissions": ["network:api.example.com"]}, headers=user,
|
||||
)
|
||||
assert enabled.status_code == 201 and enabled.json()["enabled"] is True
|
||||
# 调用成功(manifest 模板回填)
|
||||
invoked = await client.post(
|
||||
"/v1/plugins/example.fetch/invocations", json={"input": {}}, headers=user
|
||||
)
|
||||
assert invoked.status_code == 200
|
||||
assert invoked.json()["response"] == "已按声明式规则处理"
|
||||
assert invoked.json()["runtime"] == "declarative"
|
||||
|
||||
async def test_grant_outside_manifest_rejected(self, client) -> None:
|
||||
publisher = await login(client, "pl2-pub@example.com")
|
||||
await self._publish(client, publisher, "1.0.0", self.MANIFEST_V1)
|
||||
user = await login(client, "pl2-user@example.com")
|
||||
installation = await client.post(
|
||||
"/v1/plugins/example.fetch/installations",
|
||||
json={"version": "1.0.0"}, headers=user,
|
||||
)
|
||||
overreach = await client.post(
|
||||
f"/v1/plugin-installations/{installation.json()['id']}/enablings",
|
||||
json={"granted_permissions": ["device:camera"]}, headers=user,
|
||||
)
|
||||
assert overreach.status_code == 422 # 授权越界
|
||||
|
||||
async def test_upgrade_requires_reconfirmation_of_new_permissions(self, client) -> None:
|
||||
publisher = await login(client, "pl3-pub@example.com")
|
||||
await self._publish(client, publisher, "1.0.0", self.MANIFEST_V1)
|
||||
await self._publish(client, publisher, "2.0.0", self.MANIFEST_V2)
|
||||
user = await login(client, "pl3-user@example.com")
|
||||
installation = await client.post(
|
||||
"/v1/plugins/example.fetch/installations",
|
||||
json={"version": "1.0.0"}, headers=user,
|
||||
)
|
||||
iid = installation.json()["id"]
|
||||
await client.post(
|
||||
f"/v1/plugin-installations/{iid}/enablings",
|
||||
json={"granted_permissions": ["network:api.example.com"]}, headers=user,
|
||||
)
|
||||
# 升级不确认新增权限:422
|
||||
blocked = await client.post(
|
||||
f"/v1/plugin-installations/{iid}/upgrades",
|
||||
json={"version": "2.0.0"}, headers=user,
|
||||
)
|
||||
assert blocked.status_code == 422
|
||||
assert "重新确认" in blocked.json()["detail"]
|
||||
# 带新增授权:成功
|
||||
ok = await client.post(
|
||||
f"/v1/plugin-installations/{iid}/upgrades",
|
||||
json={"version": "2.0.0",
|
||||
"granted_permissions": ["network:api.example.com", "data:local_files"]},
|
||||
headers=user,
|
||||
)
|
||||
assert ok.status_code == 201
|
||||
assert ok.json()["granted_permissions"] == [
|
||||
"data:local_files", "network:api.example.com",
|
||||
]
|
||||
|
||||
async def test_withdrawal_blocks_new_invocations_keeps_history(self, client, db) -> None:
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
from app.models import AuditEvent
|
||||
|
||||
publisher = await login(client, "pl4-pub@example.com")
|
||||
await self._publish(client, publisher, "1.0.0", self.MANIFEST_V1)
|
||||
user = await login(client, "pl4-user@example.com")
|
||||
installation = await client.post(
|
||||
"/v1/plugins/example.fetch/installations",
|
||||
json={"version": "1.0.0"}, headers=user,
|
||||
)
|
||||
await client.post(
|
||||
f"/v1/plugin-installations/{installation.json()['id']}/enablings",
|
||||
json={"granted_permissions": ["network:api.example.com"]}, headers=user,
|
||||
)
|
||||
await client.post(
|
||||
"/v1/plugins/example.fetch/invocations", json={"input": {}}, headers=user
|
||||
)
|
||||
# 撤架(发布者)
|
||||
withdrawn = await client.post(
|
||||
"/v1/plugins/example.fetch/withdrawals", headers=publisher
|
||||
)
|
||||
assert withdrawn.json()["status"] == "removed"
|
||||
# 新调用阻断
|
||||
blocked = await client.post(
|
||||
"/v1/plugins/example.fetch/invocations", json={"input": {}}, headers=user
|
||||
)
|
||||
assert blocked.status_code == 403
|
||||
# 新安装阻断
|
||||
new_install = await client.post(
|
||||
"/v1/plugins/example.fetch/installations",
|
||||
json={"version": "1.0.0"}, headers=await login(client, "pl4-new@example.com"),
|
||||
)
|
||||
assert new_install.status_code == 404
|
||||
# 历史审计保留
|
||||
async with db() as session:
|
||||
audits = list(
|
||||
(
|
||||
await session.execute(
|
||||
sa_select(AuditEvent).where(
|
||||
AuditEvent.aggregate_id == "example.fetch",
|
||||
AuditEvent.action == "plugin.invoked",
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
)
|
||||
assert len(audits) == 1 # 撤架前那次调用仍在审计链
|
||||
Reference in new issue
Block a user