test(market): U0 spike 钉 KB 物化共享越权基线 + 拍 D1=S 回填
market install KB 物化 U0 spike 产 D1 决策证据(只 spike 测试零业务代码):MuseKnowledgeRetrievalApiImplTest +2 case(13/0 独立复跑确认)——①他租户私有 chunk 原样返回证伪租户过滤 ②ArgumentCaptor 抓 RetrieveChunksCommand documentIds/metadataFilter 均 null 证整库扫描零隔离;同作 U4 隔离回归反向基线(隔离补到位后应转红)。活体真 RAGFlow 整库返回坐实。 U0 揪出潜伏 bug:检索发 metadata_filter 但 RAGFlow 官方契约(context7)是 metadata_condition→被静默忽略(乱值条件仍返 baseline)、字段名修正纳入 U3。用户拍 D1=S(补隔离后共享、真最小留痕):临时-03 回填 S 分支定调(隔离优先 metadata_condition+物化打安装维度元数据+U4 复用 spike 反向基线)。进度总账回写方向全链路。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
76a7e0d312
commit
a8a26983fc
@ -8,6 +8,8 @@
|
||||
- 配套人读图:[`临时-03-market-install-KB物化执行plan.html`](临时-03-market-install-KB物化执行plan.html)
|
||||
|
||||
> **一句话**:拍板已定——做 **B'(installed_ref 最小留痕)+ KB 先行**(agent 物化下一阶段、本 plan 不含 agent)。本 plan 把 B' 拆成 **U0…U4 五个可独立验证的实现单元**,物化落点在**知识库目标域绑定路径**(`MuseKnowledgeBindingService`,非 install)。但调查坐实了一个比评审版更硬的事实:**按当前代码,多租户"共享发布者同一 RAGFlow dataset"必然越权**(RAGFlow 不感知 muse 租户、检索链对返回 chunk 无任何租户/授权二次过滤)。因此 **U0 不是"验证隔离是否隔得住",而是"在共享落地前先把 chunk 级隔离补上"**——U0 是真前置硬门:U0 绿(隔离补到位)才允许"共享 dataset";U0 红/不做则本期 KB 物化只能走**每安装者独立复制 dataset(局部 C)**,或退回**暂缓**。这一岔路必须人类拍板后才进 U1。
|
||||
>
|
||||
> **2026-06-26 更新(D1 已拍 = S 补隔离后共享)**:U0 spike 已执行并坐实证据——spike 单测 13/0(2 case 钉死越权:他租户私有 chunk 原样返回 + `RetrieveChunksCommand` 的 documentIds/metadataFilter 均 null = 整库扫描)+ 活体真 RAGFlow 整库返回;并**揪出新潜伏 bug**:检索发 `metadata_filter`,但 RAGFlow 官方契约(context7 `/infiniflow/ragflow`)是 `metadata_condition`,被 RAGFlow **静默忽略**(活体对照:乱值条件仍返 baseline)。**S 分支据此定调**:① 隔离手段优先 `metadata_condition`(活体验证生效,document_ids 限定为备选),`metadata_filter→metadata_condition` 字段名修正纳入 U3 隔离前置;② 物化时给文档打安装维度元数据(否则 metadata_condition 把无元数据文档全滤为 0);③ U4 隔离回归复用 U0 的 2 个 spike case 作反向基线(隔离补到位后应从"原样返回他租户 chunk"转红)。spike 测试 +69 行已落 `MuseKnowledgeRetrievalApiImplTest`(零业务代码)。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -193,6 +193,8 @@
|
||||
- ✅ **2026-06-25(同日续):studio e2e 56/56 全绿收尾(SSE 慢路径活体补证 + agent-slot-bind flaky 根治,commit d1917b4)**。SSE 修复(c77b006)收尾全量验证连跑 3 轮(56/56/55-1):本 session 4 修复 spec(knowledge 多 binding/AI SSE/source 污染/乐观锁)3 轮 3/3 全绿、无交叉回归;**AI SSE 慢路径活体补证**——第 3 轮 ai-gen 36s/accept 47s 真>30s 仍绿(旧 30s 死线必红、新 240s 死线+前端重连扛住),消除上轮"本机未自然>30s"残留风险。唯一失败=agent-slot-bind:94 预存 flaky(~64% 失败、commit 1fbe7ae 6-24 补 e2e 引入、非本 session 回归、DB 干净产品逻辑正确):getByText("当前:xxx v1")全局 strict 命中 2 元素(AgentPage 沙盒试用区回显 selectedAgent + SlotBindingPanel 槽位 boundAgentName、选中 agent==已绑时同文案)与槽位异步读时机竞速;收窄到 div.rounded-2xl filter「作品槽位绑定」槽位面板容器内断言(不改产品、仿套件 locator().filter() scope),**10 次独立循环 10/10 全绿根治**(方法学:非 --repeat-each——globalSetup 单进程单次复位会致 revision 1→2 漂移伪红,改逐次独立 playwright test 每轮重跑复位)。**至此本 session e2e 阻塞全清:admin governance 12 真连 0 skip + studio 56/56**。
|
||||
- ✅ **2026-06-25(同日续):「继续验证修复」收尾——e2e 最新态确认 + CI 单测全绿(全 reactor 2834/0/0,commit 7ee9cc8/effb3f8)**。①**e2e 确认**:admin governance 12/12(连跑 2 轮、SSE server 重启无影响;agent 订正实际 12 test 非 14——6bd9030 拆 governance config 为 6 子系统后定数)+ studio 56/56;本 session 改动无回归(后端 BUILD SUCCESS、前端 studio vitest 107+tsc / admin vitest 345+vue-tsc 全绿)。②**全 reactor 单测健康扫揪修 3 个预存历史红(均非本 session、自包含装配/清单缺陷)**:knowledge `MuseKnowledgeBindingServiceTest` 漏 @Mock `MarketHandoffTokenApi`→@InjectMocks null→service verify NPE(7ee9cc8、补 @Mock+stub valid)、pay 4 用例 `application-unit-test.yaml` 的 `data:` 缩进 4 空格错挂 `sql:` 下→`spring.data.redis.port=16379` 失效退默认 6379→命中本机带密码真 Redis NOAUTH(effb3f8、回退 2 空格对齐;源自初始导入 43d6806 继承 yudao、8963383 首次全绿时 6379 无密码侥幸过)、muse-server `P1rApiCoverageReportTest` 覆盖报告 `p1r-api-coverage.json` 16 处 completed 指向已删 `UnavailableMetaImpactFacade`(4bd292f 用 RealMetaImpactFacade 替换时报告未同步,effb3f8、16 处改替代者 JSON 合法)。修后全 reactor `mvn -o test`(61 模块跳 IT failsafe)**2834 tests 0F/0E BUILD SUCCESS**;独立复跑 PayOrderServiceTest 46/0、P1rApiCoverageReportTest 8/0、knowledge 模块 250/0 真转绿(不全信 agent 复验)。③**分支订正**:实际工作分支是 `dev/1.0.0`(集成分支、历史 d303279 已 merge feature/agent-dev-infrastructure 进来),本 session commit 连续落此分支(session 开头快照 feature/* 已过时、非落错)。
|
||||
- ✅ **2026-06-26:抽样关键链路 P1r 真 PG IT 验证(用户选抽样、6/6 全绿,commit a0569c6)**。从 50 个 P1r IT 选 6 个关键链路抽样(本 session knowledge 多 binding 停用 + AI 生成→采纳 ADR-020 + AI runtime e2e + 知识检索 P-A/B/C + market handoff),真 PG muse_slice_live + New-API/RAGFlow 真外部跑 **6/6 全绿**。修 1 个预存红(非本 session、测试基建跟上产品演进):`P1rAiRuntimeEndToEndLiveAcceptanceIT` TARGET_VERSION 13→31(ADR-020 V30 把 muse_ai_suggestion.authorization_snapshot_id BIGINT→VARCHAR、停 V13 bigint 类型不匹配)+ 补 MuseAiEventPublishOutboxService 空实现/KnowledgeRetrievalFacade empty context 两 bean(ADR-020 outbox+P-A 检索演进新增 @Resource 依赖、IT 上下文未跟上;校验属其他 IT 职责、与 P1rContentMergeGeneratedSuggestionIT 同源),核心 AI 生成→runtime_call→落库断言不变。**anti-false-green**:核实 diff 合理非放宽 + muse-server test-compile BUILD SUCCESS(独立跑因 IT `flyway.clean()` DROP `_test` 库风险未做、改以核实 diff+编译+agent 双报全绿替代)。抽样 6/6 后**用户选继续→全量 50 P1r IT 全跑 50/50 全绿(199 用例 0F/0E/1S、1 skip=RagFlow GraphRAG assumeTrue、含 4 个 LiveAcceptance 真打 New-API/RAGFlow,commit 94545ff)**:再修 2 个预存红(均非本 session)——`P1rContentPlanningCompletedApprovalIT`(TARGET_VERSION 21→31、savePlanningItem 依赖 V25 新增 muse_content_planning_field_snapshot 表 commit 94a2379、停 V21 缺表 500;核心 planning fact/command/outbox 断言不放宽)+ 4 个 live-acceptance **第二代理坑**(已记 SOCKS 清理只清 socksProxyHost、但 macOS 系统代理 127.0.0.1:7897 仍被 JVM 注入 http.proxyHost/https.proxyHost、`java.net.http.HttpClient` 走死代理→502/UNAVAILABLE;反误判=`curl --noproxy '*'` 直打同地址 health/completions 全 200 证外部健康、非外部波动;修=argLine 加 `-Dhttp.proxyHost= -Dhttps.proxyHost= -Djava.net.useSystemProxies=false`),蒸馏 external-deps-and-gotchas。**anti-false-green**:核实 Planning diff 仅 schema 版本非放宽 + 独立核实生产切片库 **muse_slice_live 167 表+数据完整未被 IT flyway.clean 误删**(work 真名 muse_content_work 我曾误用 muse_work 虚惊;IT 全走 muse_p1r_*_test 隔离库、_test 铁律遵守)。**至此本 session 验证三层全维度全绿:e2e(admin 12 + studio 56)+ CI 单测(全 reactor 2834/0)+ 真 PG IT(全量 50/50)**。
|
||||
- ✅ **2026-06-26:market install 下游物化方向——评审→拍板 B'/KB 先行→执行版 plan→U0 spike→拍 D1=S(commit 26ff1af/76a7e0d、U0 spike 产物待审)**。三层验证全绿后用户选"market install 后下游物化"方向。**深调研(2 agent corroborate+spot-check)**:install 只写 `muse_market_installation`+license projection、与 ai/knowledge 完全解耦、不物化用户空间 agent/kb;两断点=①无 install→ai/knowledge 桥 ②bind/slot 只建引用绑定(KB:binding+projection 指 sourceId、无本地 `muse_knowledge_base` 行/dataset→检索 no_dataset 第四门静默省略;agent:slot 放宽但 runtime `requireVisibleAgent` 拒裸引用)→用户 install 后不可真用(P1 knowledge handoff e2e 绿是 fixture 巧合 asset1→kb_id=1 恰有 seed dataset)。**评审版(临时-02)**:C/B'/B/暂缓 四方案,handoff 非物化桥(只跳转+消费域内部写)、物化落点应在目标域绑定路径、安装解耦正确不动;用户拍 **B'(installed_ref 最小留痕)+ KB 先行**(agent 物化下一阶段)。**执行版 plan(临时-03)**:U0-U4 实现单元,一手核验揪四硬事实(kb_id 被 assetId 污染、跨 BC 反查链断需新 `MarketAssetSourceApi`、共享 dataset 必越权、`muse_source_propagation_target` 表不存在)。**U0 spike(产 D1 证据、只 spike 测试零业务代码)**:spike 单测 13/0 钉死越权(他租户 chunk 原样返回+documentIds/metadataFilter 均 null 整库扫描、独立复跑 BUILD SUCCESS 确认)+活体真 RAGFlow 整库返回;**揪出潜伏 bug**:检索发 `metadata_filter` 但 RAGFlow 官方契约(context7 `/infiniflow/ragflow`)是 `metadata_condition`→被静默忽略(乱值条件仍返 baseline、活体对照)。**用户拍 D1=S(补隔离后共享)**:真最小留痕/省存储;S 分支据 U0 定调=隔离优先 `metadata_condition`(活体验证生效)+物化打安装维度元数据+`metadata_filter→metadata_condition` 字段修正纳入 U3+U4 隔离回归复用 spike 2 反向基线。**下一步执行 U1-U4(S 分支)**。anti-false-green:每 agent 报告独立 spot-check(install 解耦/dataset 门/kb_id 污染/共享越权/表不存在均亲验)、spike 单测独立复跑、字段 bug 经 context7 官方契约+活体双证。
|
||||
|
||||
### D. gateway 接线(B4,**配置层已收口;运行态后置**)
|
||||
- ✅ **配置层收口(2026-06-19)**:`muse-gateway` 已新增 `/admin-api/muse/**`、`/app-api/muse/** → grayLb://muse-server`,删除当前聚合入口未启用的 bpm/pay/report/mp 死路由,Knife4j 聚合入口改为 `muse-server`。新增 `GatewayRouteConfigurationTest` 机械门禁,断言 Muse 路由存在、停用模块路由不回潮、Knife4j 不展示停用服务;本轮 `mvn -pl muse-gateway test -Dtest=GatewayRouteConfigurationTest` **3/3 绿**。
|
||||
- ⏸ **运行态仍后置**:单体 P1R-7 仍以 `muse-server` 直服 48080 `/app-api` 为验收关键路径;`muse-server` 默认禁用 Nacos 注册,且 gateway 与 server 本地默认端口同为 48080,所以本轮不声称网关真实转发已完成。后续若启用网关部署,需补注册发现/端口方案并做 curl/e2e 首跑。
|
||||
|
||||
@ -17,6 +17,7 @@ import cn.iocoder.muse.module.knowledge.dal.mysql.muse.MuseKnowledgeBindingMappe
|
||||
import cn.iocoder.muse.module.knowledge.dal.mysql.muse.MuseKnowledgeRagflowBindingMapper;
|
||||
import cn.iocoder.muse.module.knowledge.dal.mysql.muse.MuseKnowledgeSourceBindingProjectionMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
|
||||
@ -26,8 +27,10 @@ import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ -217,4 +220,70 @@ class MuseKnowledgeRetrievalApiImplTest extends BaseMockitoUnitTest {
|
||||
RetrievalResult result = retrievalApi.retrieveForWork(req());
|
||||
assertEquals("no_chunk", result.omittedReason());
|
||||
}
|
||||
|
||||
// ========================================================================================
|
||||
// U0 SPIKE(临时-03 §U0 / 决策门 D1):把"多租户共享同一 RAGFlow dataset 必越权"钉成可复现证据。
|
||||
// 这是测试代码(spike),不是业务代码;不改任何检索/绑定主路径。
|
||||
// 跑绿 = 越权事实成立、当前检索链对 RAGFlow 返回的 chunk 无任何按 request.tenantId() 的二次过滤,
|
||||
// 共享分支(S)在未补 chunk 级隔离前不可落地;本 case 同时作为 U4 隔离回归的反向基线(隔离补到位后应转红)。
|
||||
// 上下文:muse 的租户隔离只保证"A 拿不到指向别人 dataset 的 binding";一旦设计上让两租户 binding
|
||||
// 指向同一 dataset_id,dataset_id 进入检索后 document_ids=null → RAGFlow 整库返回,
|
||||
// parseChunks 仅按 dataset 反查授权来源贴归因,绝不丢弃"不属于本租户"的 chunk。
|
||||
// ========================================================================================
|
||||
|
||||
/**
|
||||
* SPIKE-越权证据(必产):模拟 B' 共享场景——T_A 的 work 绑定指向"发布者/T_B 的 dataset D",
|
||||
* D 中混入只有 T_B 该看的、含唯一探针 token 的 chunk;以 T_A 上下文检索时,断言该越权 chunk 被<b>原样返回</b>。
|
||||
* 证伪"检索链有租户过滤":返回结果包含 MAGIC_FOREIGN_TENANT_B 即坐实"共享即越权"。
|
||||
*/
|
||||
@Test
|
||||
void spike_should_returnForeignTenantChunkVerbatim_provingNoTenantLevelFilter() {
|
||||
// T_A 上下文(req() 的 tenantId=100),其 work 的 active binding 含 search 用途 + 授权快照,
|
||||
// 但该 binding 指向的是"发布者/T_B 共享的 dataset D"(模拟 installed_ref 共享)。
|
||||
when(bindingMapper.selectActiveByWorkId(4001L)).thenReturn(List.of(authorizedBinding(5001L)));
|
||||
when(projectionMapper.selectActiveByWorkId(4001L)).thenReturn(List.of(projection(5001L, "active")));
|
||||
when(ragflowBindingMapper.selectActiveDatasetByKbId(5001L)).thenReturn(dataset("shared-dataset-D"));
|
||||
// RAGFlow 对共享 dataset D 整库返回:既有公共内容,也含一段"只有 T_B 该看"的越权探针 chunk。
|
||||
String foreignTenantChunk = "MAGIC_FOREIGN_TENANT_B_这段只有他租户该看见";
|
||||
String json = "{\"code\":0,\"data\":{\"chunks\":["
|
||||
+ "{\"content\":\"公共可见内容\",\"document_id\":\"doc-pub\",\"id\":\"c-pub\",\"kb_id\":\"shared-dataset-D\",\"similarity\":0.81},"
|
||||
+ "{\"content\":\"" + foreignTenantChunk + "\",\"document_id\":\"doc-B-private\",\"id\":\"c-B\",\"kb_id\":\"shared-dataset-D\",\"similarity\":0.95}"
|
||||
+ "],\"total\":2}}";
|
||||
when(ragFlowClient.retrieveChunks(any(RetrieveChunksCommand.class))).thenReturn(retrievalSuccess(json));
|
||||
|
||||
RetrievalResult result = retrievalApi.retrieveForWork(req());
|
||||
|
||||
// 越权事实:T_A 检索结果原样含 T_B 私有 chunk —— 检索链没有任何按 tenantId 的二次过滤。
|
||||
assertTrue(result.hasChunks(), "spike 前提:检索应返回 chunk(证明走到了 parseChunks)");
|
||||
assertEquals(2, result.chunks().size(), "两条 chunk 全部透出,越权 chunk 未被丢弃");
|
||||
boolean leaked = result.chunks().stream()
|
||||
.anyMatch(c -> c.contentSummary() != null && c.contentSummary().contains("MAGIC_FOREIGN_TENANT_B"));
|
||||
assertTrue(leaked, "越权坐实:T_A 上下文检索原样拿到 T_B 私有 chunk(共享 dataset 必越权)");
|
||||
}
|
||||
|
||||
/**
|
||||
* SPIKE-隔离参数缺位证据(必产):抓取实际发往 RAGFlow 的 RetrieveChunksCommand,
|
||||
* 断言 document_ids 与 metadata_filter 均为 null —— 检索是"按授权 dataset 整库扫描",
|
||||
* 当前根本没有把 chunk 限定到"本安装授权子集"的任何手段。这是越权的机制根因,也指出
|
||||
* 补隔离(document_ids 限定 / 元数据过滤)的落点必须改 RetrieveChunksCommand 构造处。
|
||||
*/
|
||||
@Test
|
||||
void spike_should_sendNullDocumentIdsAndMetadataFilter_provingWholeDatasetScan() {
|
||||
when(bindingMapper.selectActiveByWorkId(4001L)).thenReturn(List.of(authorizedBinding(5001L)));
|
||||
when(projectionMapper.selectActiveByWorkId(4001L)).thenReturn(List.of(projection(5001L, "active")));
|
||||
when(ragflowBindingMapper.selectActiveDatasetByKbId(5001L)).thenReturn(dataset("shared-dataset-D"));
|
||||
when(ragFlowClient.retrieveChunks(any(RetrieveChunksCommand.class)))
|
||||
.thenReturn(retrievalSuccess("{\"code\":0,\"data\":{\"chunks\":["
|
||||
+ "{\"content\":\"x\",\"document_id\":\"d\",\"id\":\"c\",\"kb_id\":\"shared-dataset-D\",\"similarity\":0.5}]}}"));
|
||||
|
||||
retrievalApi.retrieveForWork(req());
|
||||
|
||||
ArgumentCaptor<RetrieveChunksCommand> captor = ArgumentCaptor.forClass(RetrieveChunksCommand.class);
|
||||
verify(ragFlowClient).retrieveChunks(captor.capture());
|
||||
RetrieveChunksCommand sent = captor.getValue();
|
||||
// 整库扫描的证据:检索只带 dataset_ids + question,不带任何把 chunk 限定到授权子集的隔离参数。
|
||||
assertEquals(List.of("shared-dataset-D"), sent.ragflowDatasetIds());
|
||||
assertNull(sent.ragflowDocumentIds(), "document_ids 为 null → RAGFlow 对该 dataset 整库返回,无文档级隔离");
|
||||
assertNull(sent.metadataFilter(), "metadata_filter 为 null → 无元数据级 chunk 过滤");
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user