fix(agent): preserve bound subscription owner for channel users

This commit is contained in:
jxxghp
2026-09-30 17:48:51 +08:00
parent 08123fa5cf
commit b6f3d37bd1
5 changed files with 86 additions and 7 deletions

View File

@@ -110,14 +110,15 @@ API_FIRST_BATCH_OPERATION_SPECS: tuple[ApiOperationSpec, ...] = (
_spec("media.scrape", effect=ActionEffect.EXTERNAL_SIDE_EFFECT, required_role=_ADMIN, confirmation=_CONFIRM),
_spec("media.episode_schedule"),
_spec("media.detail"),
_write("subscription.add"),
_write("subscription.update"),
# 订阅归属由 API 当前用户决定,渠道管理员也必须使用绑定用户身份。
_user_write("subscription.add"),
_user_write("subscription.update"),
_user_write("subscription.search", effect=ActionEffect.EXTERNAL_SIDE_EFFECT),
_spec("subscription.list"),
_spec("subscription.shares"),
_spec("subscription.popular"),
_spec("subscription.history"),
_write("subscription.delete", effect=ActionEffect.DESTRUCTIVE_WRITE, recovery=_DELETE_RECOVERABLE),
_user_write("subscription.delete", effect=ActionEffect.DESTRUCTIVE_WRITE, recovery=_DELETE_RECOVERABLE),
_spec("download.add", effect=ActionEffect.EXTERNAL_SIDE_EFFECT, confirmation=_CONFIRM, recovery=_IDEMPOTENT),
_spec(
"download.artist_collection",

View File

@@ -897,6 +897,9 @@ SDK method。普通 MCP 客户端如需这些 provider 原生能力,应使用
宿主按 `operation_id` 决定固定 method 与 path,使用真实持久化管理员身份为
API KEY 集成签发短期本机令牌,并按 operation 执行权限、确认、结果脱敏和恢复策略。
调用方不能注入 host、URL、认证头或 API Token。
通知渠道的 `subscription.add`、`subscription.update`、`subscription.delete` 使用渠道账号绑定的
有效 MoviePilot 用户身份,渠道管理员也不会借用超级管理员身份。创建时订阅归属绑定用户,
普通用户只能修改或删除自己的订阅;未绑定或绑定用户已停用时拒绝执行。三项操作仍保留确认机制。
Web Agent 直接调用 `moviepilot_api` 时,宿主会自动加载 `moviepilot-api` Skill
的 operation 白名单后再执行;这只是授权兜底,不会放宽固定 operation、身份、权限
或确认策略。

View File

@@ -1,6 +1,6 @@
---
name: moviepilot-api
version: 37
version: 38
description: >-
Use this skill for MoviePilot product operations such as media search, torrent
search, downloads, subscriptions, library checks, sites, storage, workflows,
@@ -78,6 +78,13 @@ only `operation_id`, `path_params`, `query`, and `body`. The host chooses the
fixed HTTP method and path, creates the current user's authentication token,
applies authorization and confirmation policy, and returns the API response.
`subscription.add`, `subscription.update`, and `subscription.delete` require
confirmation and use the active MoviePilot user bound to the channel account,
including for channel administrators. Creation belongs to that user; ordinary
users may update or delete only their own subscriptions. An unbound or inactive
channel user must bind an active account before retrying, not switch to an
administrator identity.
This file is intentionally kept as the routing and execution guide. Detailed
operation contracts live in the linked category files under `api/`; load only
the one category file needed for the selected operation. Do not load every

View File

@@ -2,7 +2,7 @@ import asyncio
import json
import re
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock, Mock, patch
import pytest
@@ -28,6 +28,7 @@ from app.agent.tools.impl.agent_task import AgentTaskTool
from app.agent.tools.impl.api import MoviePilotApiTool
from app.agent.tools.impl.execute_command import ExecuteCommandTool
from app.agent.tools.manager import MoviePilotToolsManager
from app.schemas.types import NotificationChannel
def test_api_operation_registry_matches_migration_batches() -> None:
@@ -465,7 +466,7 @@ def test_policy_classifies_api_operation_by_operation_id() -> None:
)
assert delete_policy.effect is ActionEffect.DESTRUCTIVE_WRITE
assert delete_policy.required_role is PrincipalRole.SYSTEM_ADMIN
assert delete_policy.required_role is PrincipalRole.USER
assert delete_policy.confirmation is ConfirmationMode.REQUIRED
assert unknown_policy.machine_allowed is False
@@ -597,6 +598,73 @@ def test_gateway_maps_verified_channel_admin_only_for_admin_operation() -> None:
assert identity == ("7", "admin", True)
@pytest.mark.parametrize("is_channel_admin", [False, True])
@pytest.mark.parametrize("user_id,username", [(11, "alice"), (12, "bob")])
@pytest.mark.parametrize(
"operation_id,arguments",
[
("subscription.add", {"body": {"name": "示例"}}),
("subscription.update", {"body": {"id": 1}}),
("subscription.delete", {"path_params": {"subscribe_id": 1}}),
],
)
def test_subscription_writes_keep_bound_channel_identity(
is_channel_admin, user_id, username, operation_id, arguments,
) -> None:
"""不同飞书发送者的订阅写入使用各自绑定用户,不因渠道管理员资格提权。"""
users = SimpleNamespace(
find_name_by_bindings=Mock(return_value=username),
async_get_by_name=AsyncMock(return_value=SimpleNamespace(
id=user_id, name=username, is_active=True, is_superuser=False,
)),
)
gateway = MoviePilotApiTool(
session_id="session", user_id=f"ou_{username}", data=SimpleNamespace(users=users),
)
gateway.set_message_attr(channel=NotificationChannel.Feishu.value, source="main-bot", username="")
gateway.set_agent_context({"is_admin": is_channel_admin})
policy = DEFAULT_TOOL_POLICY_REGISTRY.resolve(
tool_name="moviepilot_api", arguments={"operation_id": operation_id}, requires_admin=False,
)
assert policy.required_role is PrincipalRole.USER
assert policy.confirmation is ConfirmationMode.REQUIRED
with patch("app.agent.tools.impl.api.MoviePilotApiExecutor") as executor_type, patch(
"app.application.security.auth.build_superuser_token_payload",
) as superuser:
executor_type.return_value.execute = AsyncMock(return_value='{"success": true}')
result = asyncio.run(gateway.run(operation_id=operation_id, **arguments))
assert json.loads(result)["success"] is True
context = executor_type.call_args.kwargs["context"]
assert (context.user_id, context.username, context.is_admin) == (str(user_id), username, False)
users.find_name_by_bindings.assert_called_once_with({
"feishu_userid": f"ou_{username}", "feishu_openid": f"ou_{username}",
})
superuser.assert_not_called()
@pytest.mark.parametrize("is_bound", [False, True])
def test_subscription_write_rejects_unbound_or_inactive_channel_admin(is_bound) -> None:
"""渠道管理员未绑定有效用户时不得回退超级管理员创建订阅。"""
users = SimpleNamespace(
find_name_by_bindings=Mock(return_value="disabled" if is_bound else None),
async_get_by_name=AsyncMock(return_value=SimpleNamespace(is_active=False)),
)
gateway = MoviePilotApiTool(
session_id="session", user_id="ou_user", data=SimpleNamespace(users=users),
)
gateway.set_message_attr(channel=NotificationChannel.Feishu.value, source="main-bot", username="")
gateway.set_agent_context({"is_admin": True})
with patch("app.agent.tools.impl.api.MoviePilotApiExecutor") as executor_type, patch(
"app.application.security.auth.build_superuser_token_payload",
) as superuser:
result = asyncio.run(gateway.run(operation_id="subscription.add", body={"name": "示例"}))
assert json.loads(result)["error"] == "operation_unavailable"
executor_type.assert_not_called()
superuser.assert_not_called()
def test_factory_uses_api_catalog_by_default(monkeypatch) -> None:
"""统一工具工厂默认只暴露原生能力和 API 网关。"""
monkeypatch.setattr(

View File

@@ -117,7 +117,7 @@ def test_modified_builtin_skills_have_incremented_versions() -> None:
"command-dispatch": "2",
"database-operation": "9",
"feedback-issue": "10",
"moviepilot-api": "37",
"moviepilot-api": "38",
"moviepilot-update": "5",
"organize-files": "5",
"transfer-failed-retry": "5",