mirror of
https://gitee.com/ulthon/ulthon_admin.git
synced 2026-08-30 04:35:33 +08:00
feat(mcp): McpService 密钥认证与动态工具集(白名单∩创建者实时权限)
This commit is contained in:
13
app/common/service/McpService.php
Normal file
13
app/common/service/McpService.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use base\common\service\McpServiceBase;
|
||||
|
||||
/**
|
||||
* MCP 密钥认证与工具集服务
|
||||
* Class McpService.
|
||||
*/
|
||||
class McpService extends McpServiceBase
|
||||
{
|
||||
}
|
||||
281
extend/base/common/service/McpServiceBase.php
Normal file
281
extend/base/common/service/McpServiceBase.php
Normal file
@@ -0,0 +1,281 @@
|
||||
<?php
|
||||
|
||||
namespace base\common\service;
|
||||
|
||||
use app\admin\model\SystemMcpKey;
|
||||
use app\admin\model\SystemMcpKeyNode;
|
||||
use app\admin\model\SystemMcpLog;
|
||||
use app\admin\service\NodeService;
|
||||
use app\common\service\AuthService;
|
||||
use think\facade\Cache;
|
||||
use think\facade\Db;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* MCP 密钥认证与工具集服务(纯业务逻辑层).
|
||||
*
|
||||
* 职责:
|
||||
* 1. authenticate:Bearer 明文 -> sha256 哈希比对密钥表(status=1,软删自动排除),
|
||||
* 加载创建者信息(剔除 password)
|
||||
* 2. getCreatorNodes:密钥白名单 ∩ 创建者实时权限(复用 AuthService::getAdminAllowedNodes,
|
||||
* 严禁自行写 SQL join auth_node——超管直通 / auth_on / 黑名单等语义必须与后台一致)
|
||||
* 3. getTools:按白名单顺序生成 MCP 工具定义(名称编码 + 标题描述)
|
||||
* 4. verifyNode:tools/call 传入的编码工具名 -> 校验并还原为原始节点
|
||||
* 5. logCall:调用审计落库 + 密钥表原子自增(审计失败仅记录错误日志,不抛出)
|
||||
*
|
||||
* 本类不依赖请求上下文、不依赖 mcp/sdk、不发起 HTTP(分发是端点层职责)。
|
||||
*/
|
||||
class McpServiceBase
|
||||
{
|
||||
/** MCP 规范工具名最大长度(^[a-zA-Z0-9_-]{1,64}$). */
|
||||
protected const TOOL_NAME_MAX_LENGTH = 64;
|
||||
|
||||
/** 超长编码名截断保留长度:56 + '_' + 7 位哈希 = 64. */
|
||||
protected const TOOL_NAME_TRUNCATE_KEEP = 56;
|
||||
|
||||
/** 审计日志 arguments JSON 最大长度(字符). */
|
||||
protected const LOG_ARGUMENTS_MAX_LENGTH = 2000;
|
||||
|
||||
/**
|
||||
* 密钥认证.
|
||||
*
|
||||
* 传入 Bearer 明文,sha256 后查密钥表;命中且 status=1(软删行被 SoftDelete
|
||||
* 自动排除)时加载创建者行。创建者加载方式与 AuthServiceBase::getAdminInfo
|
||||
* 保持一致(Db::name 直查,不附加 delete_time 条件),避免与权限判定链语义漂移;
|
||||
* 创建者不存在时无法建立权限基础,视为认证失败。
|
||||
*
|
||||
* @param string $bearer 密钥明文
|
||||
* @return array|null ['key_row' => SystemMcpKey模型, 'creator' => 创建者数组(无password)],失败返回 null
|
||||
*/
|
||||
public function authenticate(string $bearer): ?array
|
||||
{
|
||||
$bearer = trim($bearer);
|
||||
if ($bearer === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$keyRow = SystemMcpKey::where('key', hash('sha256', $bearer))
|
||||
->where('status', 1)
|
||||
->find();
|
||||
if (empty($keyRow)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$creator = Db::name('system_admin')
|
||||
->where('id', $keyRow->bind_admin_id)
|
||||
->find();
|
||||
if (empty($creator)) {
|
||||
return null;
|
||||
}
|
||||
unset($creator['password']);
|
||||
|
||||
return [
|
||||
'key_row' => $keyRow,
|
||||
'creator' => $creator,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算密钥可用节点:白名单 ∩ 创建者实时权限.
|
||||
*
|
||||
* 白名单顺序保持(getAdminAllowedNodes 对候选逐个判定并保序去重)。
|
||||
* 创建者权限判定完整复用 AuthService::getAdminAllowedNodes(T4):
|
||||
* 超管直通 / auth_on 开关 / 动态黑名单 / 注解 auth / status / auth_ids
|
||||
* 全部与后台一致,严禁在本类平行实现 SQL join 导致语义漂移。
|
||||
*
|
||||
* @param array $auth authenticate() 的返回结构
|
||||
* @return array 允许的节点列表(白名单顺序)
|
||||
*/
|
||||
public function getCreatorNodes(array $auth): array
|
||||
{
|
||||
$keyRow = $auth['key_row'] ?? null;
|
||||
if (empty($keyRow)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$whitelist = SystemMcpKeyNode::where('key_id', $keyRow->id)
|
||||
->column('node');
|
||||
if (empty($whitelist)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$adminId = (int) $keyRow->bind_admin_id;
|
||||
|
||||
return $this->newAuthService($adminId)
|
||||
->getAdminAllowedNodes($whitelist, $adminId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 AuthService(依赖倒置:Base 层经 app/ 入口类).
|
||||
*
|
||||
* 独立成 protected 钩子是为了可测性:auth_on 等判定链配置无法从外部注入
|
||||
* (AuthServiceBase::$config 硬编码),测试子类可覆写本方法返回定制实例。
|
||||
*/
|
||||
protected function newAuthService(int $adminId): AuthServiceBase
|
||||
{
|
||||
return new AuthService($adminId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 MCP 工具定义列表.
|
||||
*
|
||||
* 工具名 = encodeToolName(节点);描述 = 节点标题 +【原始节点】,
|
||||
* 标题取自 NodeService::getNodeParis() 的 node=>title 映射(与 AuthServiceBase
|
||||
* 共用同一 Cache key,60 秒缓存),未注册节点回退为节点串本身。
|
||||
* 全量生成后做工具名唯一性校验(编码对本项目节点字母表单射 + 截断名带
|
||||
* sha256 后缀,理论不可冲突;命中即数据异常,防御性抛出)。
|
||||
*
|
||||
* @param array $auth authenticate() 的返回结构
|
||||
* @return array [['name' => 编码名, 'description' => 标题【节点】], ...]
|
||||
*/
|
||||
public function getTools(array $auth): array
|
||||
{
|
||||
$allowedNodes = $this->getCreatorNodes($auth);
|
||||
if (empty($allowedNodes)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$nodeParis = $this->getNodeParisMap();
|
||||
|
||||
$tools = [];
|
||||
$usedNames = [];
|
||||
foreach ($allowedNodes as $node) {
|
||||
$name = $this->encodeToolName($node);
|
||||
if (isset($usedNames[$name])) {
|
||||
throw new \RuntimeException('MCP tool name collision: ' . $name);
|
||||
}
|
||||
$usedNames[$name] = true;
|
||||
|
||||
$title = $nodeParis[$node]['title'] ?? '';
|
||||
if ($title === '' || is_null($title)) {
|
||||
$title = $node;
|
||||
}
|
||||
|
||||
$tools[] = [
|
||||
'name' => $name,
|
||||
'description' => $title . '【' . $node . '】',
|
||||
];
|
||||
}
|
||||
|
||||
return $tools;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 tools/call 传入的编码工具名并还原原始节点.
|
||||
*
|
||||
* 优先按规范逆解码后命中创建者允许集合;超长节点的编码名经过截断 + 哈希
|
||||
* 后缀,不可逆解码,回退为对允许集合正向重编码逐一比对(集合很小,开销可忽略)。
|
||||
*
|
||||
* @param array $auth authenticate() 的返回结构
|
||||
* @param string $toolName 编码后的工具名
|
||||
* @return string|null 命中返回原始节点串,未授权/乱码返回 null
|
||||
*/
|
||||
public function verifyNode(array $auth, string $toolName): ?string
|
||||
{
|
||||
$allowedNodes = $this->getCreatorNodes($auth);
|
||||
if (empty($allowedNodes)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = $this->decodeToolName($toolName);
|
||||
if ($decoded !== '' && in_array($decoded, $allowedNodes, true)) {
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
foreach ($allowedNodes as $node) {
|
||||
if ($this->encodeToolName($node) === $toolName) {
|
||||
return $node;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 节点串编码为 MCP 工具名.
|
||||
*
|
||||
* MCP 工具名规范 ^[a-zA-Z0-9_-]{1,64}$,节点串(module.controller/action)
|
||||
* 含 '.' 与 '/' 非法,编码规则:先 '/'->'--' 再 '.'->'-'(顺序固定,
|
||||
* '--' 是 '/' 的唯一标记,保证对实际节点字母表单射可逆)。
|
||||
* 超过 64 字符时截断至 56 位 + '_' + sha256(原始节点) 前 7 位(56+1+7=64)。
|
||||
*/
|
||||
public function encodeToolName(string $node): string
|
||||
{
|
||||
$name = str_replace('.', '-', str_replace('/', '--', $node));
|
||||
|
||||
if (strlen($name) > self::TOOL_NAME_MAX_LENGTH) {
|
||||
$name = substr($name, 0, self::TOOL_NAME_TRUNCATE_KEEP)
|
||||
. '_' . substr(hash('sha256', $node), 0, 7);
|
||||
}
|
||||
|
||||
return $name;
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP 工具名解码回节点串(encodeToolName 的逆变换).
|
||||
*
|
||||
* 顺序与编码相反:先 '--'->'/' 再 '-'->'.'。截断名不可逆(有损),
|
||||
* 调用方(verifyNode)需对截断场景走正向重编码比对。
|
||||
*/
|
||||
public function decodeToolName(string $name): string
|
||||
{
|
||||
return str_replace('-', '.', str_replace('--', '/', $name));
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用审计落库.
|
||||
*
|
||||
* 日志行写 system_mcp_log(arguments JSON 超长截断);密钥表 use_num 用
|
||||
* Db::raw 原子自增(禁止读改写),并刷新 last_use_time。
|
||||
* 审计属于尽力而为:写失败仅 Log::error,绝不向上抛出影响主调用流程。
|
||||
*/
|
||||
public function logCall(int $keyId, string $node, array $arguments, bool $success, int $costMs): void
|
||||
{
|
||||
try {
|
||||
$argumentsJson = json_encode($arguments, JSON_UNESCAPED_UNICODE);
|
||||
if ($argumentsJson === false) {
|
||||
$argumentsJson = '';
|
||||
}
|
||||
if (mb_strlen($argumentsJson) > self::LOG_ARGUMENTS_MAX_LENGTH) {
|
||||
$argumentsJson = mb_substr($argumentsJson, 0, self::LOG_ARGUMENTS_MAX_LENGTH);
|
||||
}
|
||||
|
||||
SystemMcpLog::create([
|
||||
'key_id' => $keyId,
|
||||
'node' => $node,
|
||||
'arguments' => $argumentsJson,
|
||||
'is_success' => $success ? 1 : 0,
|
||||
'cost_ms' => $costMs,
|
||||
]);
|
||||
|
||||
SystemMcpKey::where('id', $keyId)->update([
|
||||
'use_num' => Db::raw('use_num+1'),
|
||||
'last_use_time' => time(),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('MCP call audit failed: ' . $e->getMessage(), [
|
||||
'key_id' => $keyId,
|
||||
'node' => $node,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 节点 node=>信息 映射(含 title).
|
||||
*
|
||||
* 与 AuthServiceBase::getNodeList 共用同一 Cache key(node_paris,60 秒),
|
||||
* 避免每次 tools/list 重复反射扫描控制器目录。
|
||||
* @return array
|
||||
*/
|
||||
protected function getNodeParisMap(): array
|
||||
{
|
||||
$cacheKey = 'node_paris';
|
||||
$nodeParis = Cache::get($cacheKey);
|
||||
if (!$nodeParis) {
|
||||
$nodeParis = (new NodeService())->getNodeParis();
|
||||
Cache::set($cacheKey, $nodeParis, 60);
|
||||
}
|
||||
|
||||
return $nodeParis;
|
||||
}
|
||||
}
|
||||
406
tests/McpServiceTest.php
Normal file
406
tests/McpServiceTest.php
Normal file
@@ -0,0 +1,406 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace tests;
|
||||
|
||||
use app\admin\model\SystemMcpKey;
|
||||
use app\admin\model\SystemMcpKeyNode;
|
||||
use app\common\service\McpService;
|
||||
use app\common\test\TestCase;
|
||||
use base\common\service\AuthServiceBase;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* McpService 单元测试(密钥认证 / 动态工具集 / 调用审计).
|
||||
*
|
||||
* 全部 fixture 在测试事务内自造(ulthon-testing 技能约定),不依赖开发库既有数据;
|
||||
* 造新管理员/新角色避开 SystemAuthNode 60 秒查询缓存与 adminInfo autoCache 窗口
|
||||
* (learnings Task 4:漂移断言不修改已读过的行,改用全新 ID)。
|
||||
*
|
||||
* 覆盖验收矩阵:
|
||||
* 1. authenticate:有效 / 错误 bearer / status=0 / 软删
|
||||
* 2. getTools:白名单 ∩ 创建者实时权限(漂移)
|
||||
* 3. 漂移矩阵:超管=全量 / auth_on=false=全量 / 创建者禁用=空
|
||||
* 4. 工具名 MCP 规范正则 + encode/decode 往返(含下划线动作名、超长截断)
|
||||
* 5. logCall:日志落库 + use_num 原子自增
|
||||
* 6. verifyNode:合法编码名 / 未授权 / 乱码
|
||||
*/
|
||||
class McpServiceTest extends TestCase
|
||||
{
|
||||
/** 真实注册节点 A(mcp_key 控制器 index,auth=true). */
|
||||
private const NODE_A = 'system.mcp_key/index';
|
||||
|
||||
/** 真实注册节点 B(驼峰动作名:parseNodeStr 只 snake 控制器段,动作段保留原样). */
|
||||
private const NODE_B = 'system.auth/toggleUser';
|
||||
|
||||
private McpService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->service = new McpService();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// fixture 工厂(事务内写入,tearDown 自动回滚)
|
||||
// =========================================================================
|
||||
|
||||
private function createAdmin(int $status = 1, string $authIds = ''): int
|
||||
{
|
||||
return Db::name('system_admin')->insertGetId([
|
||||
'username' => 'mcp_t_' . uniqid(),
|
||||
'password' => 'should-not-leak',
|
||||
'auth_ids' => $authIds,
|
||||
'status' => $status,
|
||||
'create_time' => time(),
|
||||
'update_time' => time(),
|
||||
'delete_time' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
private function createRoleWithNodes(array $nodes): int
|
||||
{
|
||||
$roleId = Db::name('system_auth')->insertGetId([
|
||||
'title' => 'mt' . uniqid(),
|
||||
'status' => 2, // system_auth 枚举:1=禁用 2=启用
|
||||
'sort' => 100,
|
||||
'create_time' => time(),
|
||||
'update_time' => time(),
|
||||
'delete_time' => 0,
|
||||
]);
|
||||
foreach ($nodes as $node) {
|
||||
Db::name('system_auth_node')->insert([
|
||||
'auth_id' => $roleId,
|
||||
'node' => $node,
|
||||
]);
|
||||
}
|
||||
|
||||
return $roleId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 造密钥 + 白名单节点.
|
||||
* @return array{id: int, plaintext: string}
|
||||
*/
|
||||
private function createKey(int $bindAdminId, array $nodes, int $status = 1, int $deleteTime = 0): array
|
||||
{
|
||||
$plaintext = 'sk-mcp-test-' . bin2hex(random_bytes(16));
|
||||
$keyId = Db::name('system_mcp_key')->insertGetId([
|
||||
'title' => 'mt' . uniqid(),
|
||||
'key' => hash('sha256', $plaintext),
|
||||
'key_prefix' => substr($plaintext, 0, 11),
|
||||
'bind_admin_id' => $bindAdminId,
|
||||
'status' => $status,
|
||||
'remark' => '',
|
||||
'use_num' => 0,
|
||||
'last_use_time' => 0,
|
||||
'create_time' => time(),
|
||||
'update_time' => time(),
|
||||
'delete_time' => $deleteTime,
|
||||
]);
|
||||
foreach ($nodes as $node) {
|
||||
SystemMcpKeyNode::create(['key_id' => $keyId, 'node' => $node]);
|
||||
}
|
||||
|
||||
return ['id' => $keyId, 'plaintext' => $plaintext];
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 1. authenticate
|
||||
// =========================================================================
|
||||
|
||||
public function test_authenticate_valid_key_returns_creator_without_password(): void
|
||||
{
|
||||
$adminId = $this->createAdmin(status: 1, authIds: '');
|
||||
$key = $this->createKey($adminId, [self::NODE_A]);
|
||||
|
||||
$auth = $this->service->authenticate($key['plaintext']);
|
||||
|
||||
$this->assertIsArray($auth);
|
||||
$this->assertArrayHasKey('key_row', $auth);
|
||||
$this->assertArrayHasKey('creator', $auth);
|
||||
$this->assertInstanceOf(SystemMcpKey::class, $auth['key_row']);
|
||||
$this->assertSame($key['id'], $auth['key_row']->id);
|
||||
$this->assertNotEmpty($auth['creator']);
|
||||
$this->assertSame($adminId, (int) $auth['creator']['id']);
|
||||
// 创建者行必须剔除 password
|
||||
$this->assertArrayNotHasKey('password', $auth['creator']);
|
||||
}
|
||||
|
||||
public function test_authenticate_rejects_wrong_bearer(): void
|
||||
{
|
||||
$adminId = $this->createAdmin();
|
||||
$this->createKey($adminId, [self::NODE_A]);
|
||||
|
||||
$this->assertNull($this->service->authenticate('sk-mcp-wrong-bearer'));
|
||||
}
|
||||
|
||||
public function test_authenticate_rejects_disabled_key(): void
|
||||
{
|
||||
$adminId = $this->createAdmin();
|
||||
$key = $this->createKey($adminId, [self::NODE_A], status: 0);
|
||||
|
||||
$this->assertNull($this->service->authenticate($key['plaintext']));
|
||||
}
|
||||
|
||||
public function test_authenticate_rejects_soft_deleted_key(): void
|
||||
{
|
||||
$adminId = $this->createAdmin();
|
||||
$key = $this->createKey($adminId, [self::NODE_A], deleteTime: time() - 60);
|
||||
|
||||
$this->assertNull($this->service->authenticate($key['plaintext']));
|
||||
}
|
||||
|
||||
public function test_authenticate_rejects_empty_bearer(): void
|
||||
{
|
||||
$this->assertNull($this->service->authenticate(''));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 2. getTools:白名单 ∩ 创建者实时权限(漂移)
|
||||
// =========================================================================
|
||||
|
||||
public function test_get_tools_intersects_whitelist_with_creator_permission(): void
|
||||
{
|
||||
// 创建者角色只剩 NODE_A 权限(NODE_B 已回收 = 漂移场景)
|
||||
$roleId = $this->createRoleWithNodes([self::NODE_A]);
|
||||
$adminId = $this->createAdmin(status: 1, authIds: (string) $roleId);
|
||||
$key = $this->createKey($adminId, [self::NODE_A, self::NODE_B]);
|
||||
|
||||
$auth = $this->service->authenticate($key['plaintext']);
|
||||
$this->assertNotNull($auth);
|
||||
|
||||
// 节点层:只剩 A
|
||||
$nodes = $this->service->getCreatorNodes($auth);
|
||||
$this->assertSame([self::NODE_A], $nodes);
|
||||
|
||||
// 工具层:仅返回 A,名称为编码名,描述含原始节点
|
||||
$tools = $this->service->getTools($auth);
|
||||
$this->assertCount(1, $tools);
|
||||
$this->assertSame($this->service->encodeToolName(self::NODE_A), $tools[0]['name']);
|
||||
$this->assertStringContainsString('【' . self::NODE_A . '】', $tools[0]['description']);
|
||||
}
|
||||
|
||||
public function test_get_tools_preserves_whitelist_order(): void
|
||||
{
|
||||
$roleId = $this->createRoleWithNodes([self::NODE_A, self::NODE_B]);
|
||||
$adminId = $this->createAdmin(status: 1, authIds: (string) $roleId);
|
||||
// 白名单故意逆序插入 [B, A],结果必须保持白名单顺序
|
||||
$key = $this->createKey($adminId, [self::NODE_B, self::NODE_A]);
|
||||
|
||||
$auth = $this->service->authenticate($key['plaintext']);
|
||||
$nodes = $this->service->getCreatorNodes($auth);
|
||||
|
||||
$this->assertSame([self::NODE_B, self::NODE_A], $nodes);
|
||||
}
|
||||
|
||||
public function test_get_tools_empty_whitelist_returns_empty(): void
|
||||
{
|
||||
$adminId = $this->createAdmin();
|
||||
$key = $this->createKey($adminId, []);
|
||||
|
||||
$auth = $this->service->authenticate($key['plaintext']);
|
||||
$this->assertNotNull($auth);
|
||||
$this->assertSame([], $this->service->getCreatorNodes($auth));
|
||||
$this->assertSame([], $this->service->getTools($auth));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 3. 漂移矩阵
|
||||
// =========================================================================
|
||||
|
||||
public function test_drift_super_admin_creator_gets_full_whitelist(): void
|
||||
{
|
||||
// 超管(seed 的 id=1,auth_ids 空):判定链超管直通,白名单全保留
|
||||
// (含未注册节点也放行——超管对全量节点可见)
|
||||
$fakeNode = 'nonexist.fake_node/action';
|
||||
$key = $this->createKey(1, [self::NODE_A, $fakeNode, self::NODE_B]);
|
||||
|
||||
$auth = $this->service->authenticate($key['plaintext']);
|
||||
$this->assertNotNull($auth);
|
||||
$this->assertSame(1, (int) $auth['creator']['id']);
|
||||
|
||||
$nodes = $this->service->getCreatorNodes($auth);
|
||||
$this->assertSame([self::NODE_A, $fakeNode, self::NODE_B], $nodes);
|
||||
|
||||
$tools = $this->service->getTools($auth);
|
||||
$this->assertCount(3, $tools);
|
||||
}
|
||||
|
||||
public function test_drift_auth_on_false_returns_full_whitelist(): void
|
||||
{
|
||||
// auth_on=false 是 AuthServiceBase 硬编码配置,经 newAuthService 钩子注入关闭实例
|
||||
$service = new class() extends McpService {
|
||||
protected function newAuthService(int $adminId): AuthServiceBase
|
||||
{
|
||||
return new class($adminId) extends \app\common\service\AuthService {
|
||||
public function __construct($adminId = null)
|
||||
{
|
||||
// 属性默认值在构造前已就位,先关闭 auth_on 再走父构造
|
||||
$this->config['auth_on'] = false;
|
||||
parent::__construct($adminId);
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// 非超管创建者:无任何角色授权,auth_on=true 时应为空
|
||||
$adminId = $this->createAdmin(status: 1, authIds: '');
|
||||
$key = $this->createKey($adminId, [self::NODE_A, self::NODE_B]);
|
||||
|
||||
$auth = $this->service->authenticate($key['plaintext']);
|
||||
$this->assertNotNull($auth);
|
||||
$this->assertSame([], $this->service->getCreatorNodes($auth), 'auth_on=true 基线:无角色应为空');
|
||||
|
||||
// auth_on=false:判定链在 auth_on 处短路,白名单全量放行
|
||||
$nodes = $service->getCreatorNodes($auth);
|
||||
$this->assertSame([self::NODE_A, self::NODE_B], $nodes);
|
||||
$this->assertCount(2, $service->getTools($auth));
|
||||
}
|
||||
|
||||
public function test_drift_creator_disabled_returns_empty_toolset(): void
|
||||
{
|
||||
// 创建者 status=0:判定链 status 校验拒绝一切 auth=true 节点
|
||||
$roleId = $this->createRoleWithNodes([self::NODE_A, self::NODE_B]);
|
||||
$adminId = $this->createAdmin(status: 0, authIds: (string) $roleId);
|
||||
$key = $this->createKey($adminId, [self::NODE_A, self::NODE_B]);
|
||||
|
||||
$auth = $this->service->authenticate($key['plaintext']);
|
||||
$this->assertNotNull($auth);
|
||||
|
||||
$this->assertSame([], $this->service->getCreatorNodes($auth));
|
||||
$this->assertSame([], $this->service->getTools($auth));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 4. 工具名编码:MCP 规范正则 + 往返一致
|
||||
// =========================================================================
|
||||
|
||||
public function test_tool_names_match_mcp_regex_and_roundtrip(): void
|
||||
{
|
||||
$key = $this->createKey(1, [self::NODE_A, self::NODE_B]);
|
||||
$auth = $this->service->authenticate($key['plaintext']);
|
||||
$tools = $this->service->getTools($auth);
|
||||
|
||||
$this->assertNotEmpty($tools);
|
||||
foreach ($tools as $tool) {
|
||||
$this->assertMatchesRegularExpression('/^[a-zA-Z0-9_-]{1,64}$/', $tool['name']);
|
||||
}
|
||||
|
||||
// 已知节点编码精确值(驼峰动作名 + 下划线动作名示例)
|
||||
$this->assertSame('system-mcp_key--index', $this->service->encodeToolName(self::NODE_A));
|
||||
$this->assertSame('system-auth--toggleUser', $this->service->encodeToolName(self::NODE_B));
|
||||
// 任务示例中的下划线动作名(纯编码函数层往返)
|
||||
$this->assertSame('system-auth--toggle_user', $this->service->encodeToolName('system.auth/toggle_user'));
|
||||
$this->assertSame('system.auth/toggle_user', $this->service->decodeToolName('system-auth--toggle_user'));
|
||||
|
||||
// 全部真实注册节点 encode/decode 往返一致(真实节点均短于截断阈值,可逆)
|
||||
$allNodes = array_keys((new \app\admin\service\NodeService())->getNodeParis());
|
||||
$this->assertNotEmpty($allNodes);
|
||||
foreach ($allNodes as $node) {
|
||||
$encoded = $this->service->encodeToolName($node);
|
||||
$this->assertMatchesRegularExpression('/^[a-zA-Z0-9_-]{1,64}$/', $encoded, 'node: ' . $node);
|
||||
$this->assertSame($node, $this->service->decodeToolName($encoded), 'roundtrip node: ' . $node);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_encode_tool_name_truncates_long_node_with_hash_suffix(): void
|
||||
{
|
||||
// 73 字符节点 -> 编码 74 字符 -> 截断 56 + '_' + sha256 前 7 位 = 64
|
||||
$longNode = 'system.' . str_repeat('x', 60) . '/index';
|
||||
$this->assertSame(73, strlen($longNode));
|
||||
|
||||
$name = $this->service->encodeToolName($longNode);
|
||||
$this->assertSame(64, strlen($name));
|
||||
$this->assertMatchesRegularExpression('/^[a-zA-Z0-9_-]{1,64}$/', $name);
|
||||
$this->assertSame(
|
||||
substr('system-' . str_repeat('x', 60) . '--index', 0, 56) . '_' . substr(hash('sha256', $longNode), 0, 7),
|
||||
$name
|
||||
);
|
||||
// 截断名有损:直接 decode 不等于原节点(verifyNode 走正向重编码兜底)
|
||||
$this->assertNotSame($longNode, $this->service->decodeToolName($name));
|
||||
|
||||
// 超管白名单放行未注册长节点:verifyNode 必须能还原(覆盖截断兜底路径)
|
||||
$key = $this->createKey(1, [$longNode]);
|
||||
$auth = $this->service->authenticate($key['plaintext']);
|
||||
$tools = $this->service->getTools($auth);
|
||||
$this->assertCount(1, $tools);
|
||||
$this->assertSame($name, $tools[0]['name']);
|
||||
$this->assertSame($longNode, $this->service->verifyNode($auth, $name));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 5. logCall:审计落库 + 原子自增
|
||||
// =========================================================================
|
||||
|
||||
public function test_log_call_writes_log_and_increments_use_num(): void
|
||||
{
|
||||
$adminId = $this->createAdmin();
|
||||
$key = $this->createKey($adminId, [self::NODE_A]);
|
||||
|
||||
$this->service->logCall($key['id'], self::NODE_A, ['a' => 1], true, 12);
|
||||
$this->service->logCall($key['id'], self::NODE_B, ['q' => str_repeat('x', 3000)], false, 34);
|
||||
|
||||
// 日志表有行
|
||||
$this->assertSame(2, Db::name('system_mcp_log')->where('key_id', $key['id'])->count());
|
||||
$this->assertDatabaseHas('system_mcp_log', [
|
||||
'key_id' => $key['id'],
|
||||
'node' => self::NODE_A,
|
||||
'arguments' => '{"a":1}',
|
||||
'is_success' => 1,
|
||||
'cost_ms' => 12,
|
||||
]);
|
||||
|
||||
// arguments JSON 截断至 2000 字符以内;失败行字段正确落库
|
||||
$longArgs = Db::name('system_mcp_log')
|
||||
->where('key_id', $key['id'])
|
||||
->where('node', self::NODE_B)
|
||||
->value('arguments');
|
||||
$this->assertLessThanOrEqual(2000, mb_strlen((string) $longArgs));
|
||||
$this->assertDatabaseHas('system_mcp_log', [
|
||||
'key_id' => $key['id'],
|
||||
'node' => self::NODE_B,
|
||||
'is_success' => 0,
|
||||
'cost_ms' => 34,
|
||||
]);
|
||||
|
||||
// use_num 原子自增:连续 2 次调用 +2;last_use_time 已刷新
|
||||
$this->assertDatabaseHas('system_mcp_key', ['id' => $key['id'], 'use_num' => 2]);
|
||||
$this->assertGreaterThan(0, (int) Db::name('system_mcp_key')->where('id', $key['id'])->value('last_use_time'));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 6. verifyNode
|
||||
// =========================================================================
|
||||
|
||||
public function test_verify_node_accepts_authorized_encoded_name(): void
|
||||
{
|
||||
$roleId = $this->createRoleWithNodes([self::NODE_A]);
|
||||
$adminId = $this->createAdmin(status: 1, authIds: (string) $roleId);
|
||||
$key = $this->createKey($adminId, [self::NODE_A, self::NODE_B]);
|
||||
|
||||
$auth = $this->service->authenticate($key['plaintext']);
|
||||
$this->assertNotNull($auth);
|
||||
|
||||
$encoded = $this->service->encodeToolName(self::NODE_A);
|
||||
$this->assertSame(self::NODE_A, $this->service->verifyNode($auth, $encoded));
|
||||
}
|
||||
|
||||
public function test_verify_node_rejects_unauthorized_and_garbage_names(): void
|
||||
{
|
||||
$roleId = $this->createRoleWithNodes([self::NODE_A]);
|
||||
$adminId = $this->createAdmin(status: 1, authIds: (string) $roleId);
|
||||
$key = $this->createKey($adminId, [self::NODE_A, self::NODE_B]);
|
||||
|
||||
$auth = $this->service->authenticate($key['plaintext']);
|
||||
$this->assertNotNull($auth);
|
||||
|
||||
// NODE_B 在白名单内,但创建者权限已回收(漂移):编码名不可用
|
||||
$this->assertNull($this->service->verifyNode($auth, $this->service->encodeToolName(self::NODE_B)));
|
||||
// 乱码名
|
||||
$this->assertNull($this->service->verifyNode($auth, 'totally_unknown_tool'));
|
||||
// 空名
|
||||
$this->assertNull($this->service->verifyNode($auth, ''));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user