diff --git a/app/agent/policy/api.py b/app/agent/policy/api.py index b8cac3af6..b0464eadb 100644 --- a/app/agent/policy/api.py +++ b/app/agent/policy/api.py @@ -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", diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 6043973b4..51e23483e 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -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、身份、权限 或确认策略。 diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index 293d3426e..5be3b521c 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -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 diff --git a/tests/test_agent_api_gateway.py b/tests/test_agent_api_gateway.py index 4c1cc6806..9f5c52230 100644 --- a/tests/test_agent_api_gateway.py +++ b/tests/test_agent_api_gateway.py @@ -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( diff --git a/tests/test_builtin_skill_boundaries.py b/tests/test_builtin_skill_boundaries.py index b5135f3fc..4d3beb5a6 100644 --- a/tests/test_builtin_skill_boundaries.py +++ b/tests/test_builtin_skill_boundaries.py @@ -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",