mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-02 21:34:32 +08:00
fix(agent): preserve bound subscription owner for channel users
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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、身份、权限
|
||||
或确认策略。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user