fix(ai): runtime 授权命中剥离 sourceSnapshotId,修 AI 执行恒 fail-closed 死结

问题: ProjectionSecurityRuntimePermissionFacade 的 runtime permission 门要求 grant.scope.sourceSnapshotId 精确匹配(SQL WHERE + Java scope 校验双重),但 sourceSnapshotId 每次 task 随机生成(ContentMuseWorkOwnerFacade:82 = "src-"+UUID)、且 grant 须在 task 提交前置存在 → admin 无法预审批含未来随机值的 grant → P1R-4 下任何 AI 执行(含 agent test)永远 fail-closed 被拒。

根因: 授权链两侧粒度语义错配——校验侧按 per-execution(含随机执行证据)匹配,产出侧仅有 per-registration(admin 预登记)能力;sourceSnapshotId 本是单次执行的事后审计证据,被误当成能力授权命中条件。

修正(最小): 从 grant 命中条件剥离 sourceSnapshotId——删 SQL WHERE 条件 + mapper 参数 + requireMatchingScope 校验;保留为 envelope summary 审计字段 + requireCompleteRequest 非空校验(执行证据仍必填)。授权回归能力粒度(operationId/targetType/targetId·targetKey/agentId/resourceRefs,均为可预登记的稳定标识)。

验证: ProjectionFacade 15(含新增 should_issueWhenScopeOmitsSourceSnapshotId)+ MuseAiTaskService 40 + ToolGrantService 6 + LocalApproval 7 全绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-24 00:52:05 -07:00
parent 1cf2ab389a
commit d647f0c8d9
4 changed files with 38 additions and 26 deletions

View File

@ -56,8 +56,7 @@ public class ProjectionSecurityRuntimePermissionFacade implements SecurityRuntim
requireCompleteRequest(request);
Long tenantId = TenantContextHolder.getRequiredTenantId();
MuseToolGrantDO grant = toolGrantMapper.selectRuntimeApprovedGrant(tenantId, request.operationId(),
request.targetType(), request.targetId(), request.targetKey(), request.sourceSnapshotId(),
request.agentId());
request.targetType(), request.targetId(), request.targetKey(), request.agentId());
if (!isApprovedSecurityProjection(grant)) {
// Security projection 不存在或不是 approved/active/security AI runtime 只能拒绝执行
throw new ServiceException(AI_RUNTIME_PERMISSION_DENIED);
@ -113,7 +112,8 @@ public class ProjectionSecurityRuntimePermissionFacade implements SecurityRuntim
requireTextEquals(scope, "operationId", request.operationId());
requireTextEquals(scope, "targetType", request.targetType());
requireMatchingTargetFields(scope, request);
requireTextEquals(scope, "sourceSnapshotId", request.sourceSnapshotId());
// sourceSnapshotId 是单次执行的运行时证据(随机生成)按设计不参与能力授权命中(详见 MuseToolGrantMapper 注释)
// 仅由 summary() 记入 envelope 作事后审计requireCompleteRequest 仍要求其非空(执行证据必须存在)
requireTextEquals(scope, "agentId", String.valueOf(request.agentId()));
List<Map<String, String>> resourceRefs = resourceRefs(scope.get("resourceRefs"));

View File

@ -27,6 +27,11 @@ public interface MuseToolGrantMapper extends BaseMapperX<MuseToolGrantDO> {
* <p>P1R-4 还没有接入 Security 主流程 RPC只能消费已落库的 ToolGrant 投影查询必须固定
* security ownerapprovedactive 三个条件scope 必须显式携带本次 runtime 可消费的资源引用避免 AI
* 模块把普通审批号或缺字段投影当成 runtime envelope</p>
*
* <p>命中按能力授权粒度匹配 operationId/targetType/targetId·targetKey/agentId/resourceRefs均为可预登记的稳定标识
* sourceSnapshotId 是单次执行的运行时证据(每次随机 "src-"+UUID 生成)属事后审计字段而非授权条件若纳入命中
* 等于要求预登记 grant 预知未来随机值逻辑无解(任何 AI 执行都永远 fail-closed)故剥离出命中条件
* 仅由 runtime envelope summary 记录追溯( ProjectionSecurityRuntimePermissionFacade)</p>
*/
@Select("""
<script>
@ -47,7 +52,6 @@ public interface MuseToolGrantMapper extends BaseMapperX<MuseToolGrantDO> {
AND scope ->> 'ownerType' IN ('system', 'user', 'work', 'knowledge', 'market')
AND scope ->> 'operationId' = #{operationId}
AND scope ->> 'targetType' = #{targetType}
AND scope ->> 'sourceSnapshotId' = #{sourceSnapshotId}
AND scope ->> 'agentId' = #{agentIdText}
AND jsonb_typeof(scope -> 'resourceRefs') = 'array'
AND jsonb_array_length(scope -> 'resourceRefs') > 0
@ -85,21 +89,19 @@ public interface MuseToolGrantMapper extends BaseMapperX<MuseToolGrantDO> {
@Param("targetId") Long targetId,
@Param("targetIdText") String targetIdText,
@Param("targetKey") String targetKey,
@Param("sourceSnapshotId") String sourceSnapshotId,
@Param("agentId") Long agentId,
@Param("agentIdText") String agentIdText);
default MuseToolGrantDO selectRuntimeApprovedGrant(Long tenantId, String operationId, String targetType,
Long targetId, String targetKey, String sourceSnapshotId) {
Long targetId, String targetKey) {
return selectRuntimeApprovedGrant(tenantId, operationId, targetType, targetId,
targetId == null ? null : String.valueOf(targetId), targetKey, sourceSnapshotId, null, null);
targetId == null ? null : String.valueOf(targetId), targetKey, null, null);
}
default MuseToolGrantDO selectRuntimeApprovedGrant(Long tenantId, String operationId, String targetType,
Long targetId, String targetKey, String sourceSnapshotId,
Long agentId) {
Long targetId, String targetKey, Long agentId) {
return selectRuntimeApprovedGrant(tenantId, operationId, targetType, targetId,
targetId == null ? null : String.valueOf(targetId), targetKey, sourceSnapshotId, agentId,
targetId == null ? null : String.valueOf(targetId), targetKey, agentId,
agentId == null ? null : String.valueOf(agentId));
}

View File

@ -1161,7 +1161,7 @@ class MuseAiTaskServiceTest extends BaseMockitoUnitTest {
when(contentWorkOwnerFacade.buildSourceSnapshot(any()))
.thenReturn(sourceSnapshot("src-projection", 9001L, null, 7001L, 8, List.of()));
when(toolGrantMapper.selectRuntimeApprovedGrant(100L, "createAiTask", "aiTask", 9001L,
"work:9001", "src-projection", 10L)).thenReturn(toolGrant(301L, 10L, "approved",
"work:9001", 10L)).thenReturn(toolGrant(301L, 10L, "approved",
"active", "security"));
when(agentMapper.selectById(10L)).thenReturn(agent(10L, "system", null));
when(agentVersionMapper.selectByAgentIdAndVersion(10L, "2")).thenReturn(agentVersion(100L, 10L, "2"));

View File

@ -43,7 +43,7 @@ class ProjectionSecurityRuntimePermissionFacadeTest extends BaseMockitoUnitTest
new SecurityRuntimePermissionFacade.RuntimePermissionRequest("createAiTask", 1001L, 1001L,
"aiTask", 9001L, "work:9001", "src-approved", 10L, 100L);
when(toolGrantMapper.selectRuntimeApprovedGrant(100L, "createAiTask", "aiTask", 9001L,
"work:9001", "src-approved", 10L)).thenReturn(approvedGrant());
"work:9001", 10L)).thenReturn(approvedGrant());
SecurityRuntimePermissionFacade.RuntimePermissionEnvelopeIssue issue = facade.issue(request);
@ -71,7 +71,7 @@ class ProjectionSecurityRuntimePermissionFacadeTest extends BaseMockitoUnitTest
new SecurityRuntimePermissionFacade.RuntimePermissionRequest("createAiTask", 1001L, 1001L,
"aiTask", 9001L, "work:9001", "src-missing", 10L, 100L);
when(toolGrantMapper.selectRuntimeApprovedGrant(100L, "createAiTask", "aiTask", 9001L,
"work:9001", "src-missing", 10L)).thenReturn(null);
"work:9001", 10L)).thenReturn(null);
ServiceException exception = assertThrows(ServiceException.class, () -> facade.issue(request));
@ -85,7 +85,7 @@ class ProjectionSecurityRuntimePermissionFacadeTest extends BaseMockitoUnitTest
new SecurityRuntimePermissionFacade.RuntimePermissionRequest("createAiTask", 1001L, 1001L,
"aiTask", 9999L, "work:9999", "src-approved", 10L, 100L);
when(toolGrantMapper.selectRuntimeApprovedGrant(100L, "createAiTask", "aiTask", 9999L,
"work:9999", "src-approved", 10L)).thenReturn(approvedGrant());
"work:9999", 10L)).thenReturn(approvedGrant());
ServiceException exception = assertThrows(ServiceException.class, () -> facade.issue(request));
@ -99,13 +99,13 @@ class ProjectionSecurityRuntimePermissionFacadeTest extends BaseMockitoUnitTest
new SecurityRuntimePermissionFacade.RuntimePermissionRequest("createAiTask", 1001L, 1001L,
"aiTask", 9001L, "work:9001", "src-approved", 10L, 100L);
when(toolGrantMapper.selectRuntimeApprovedGrant(100L, "createAiTask", "aiTask", 9001L,
"work:9001", "src-approved", 10L)).thenReturn(approvedGrantWithScope(null));
"work:9001", 10L)).thenReturn(approvedGrantWithScope(null));
ServiceException missingScope = assertThrows(ServiceException.class, () -> facade.issue(request));
assertEquals(AI_RUNTIME_PERMISSION_DENIED.getCode(), missingScope.getCode());
when(toolGrantMapper.selectRuntimeApprovedGrant(100L, "createAiTask", "aiTask", 9001L,
"work:9001", "src-approved", 10L)).thenReturn(approvedGrantWithScope("{}"));
"work:9001", 10L)).thenReturn(approvedGrantWithScope("{}"));
ServiceException emptyScope = assertThrows(ServiceException.class, () -> facade.issue(request));
assertEquals(AI_RUNTIME_PERMISSION_DENIED.getCode(), emptyScope.getCode());
@ -118,7 +118,7 @@ class ProjectionSecurityRuntimePermissionFacadeTest extends BaseMockitoUnitTest
new SecurityRuntimePermissionFacade.RuntimePermissionRequest("createAiTask", 1001L, 1001L,
"aiTask", 9001L, "work:9001", "src-approved", 10L, 100L);
when(toolGrantMapper.selectRuntimeApprovedGrant(100L, "createAiTask", "aiTask", 9001L,
"work:9001", "src-approved", 10L)).thenReturn(approvedGrant(Map.of("operationId", "testAgent")));
"work:9001", 10L)).thenReturn(approvedGrant(Map.of("operationId", "testAgent")));
ServiceException exception = assertThrows(ServiceException.class, () -> facade.issue(request));
@ -132,7 +132,7 @@ class ProjectionSecurityRuntimePermissionFacadeTest extends BaseMockitoUnitTest
new SecurityRuntimePermissionFacade.RuntimePermissionRequest("createAiTask", 1001L, 1001L,
"aiTask", 9001L, "work:9001", "src-approved", 10L, 100L);
when(toolGrantMapper.selectRuntimeApprovedGrant(100L, "createAiTask", "aiTask", 9001L,
"work:9001", "src-approved", 10L)).thenReturn(approvedGrant(Map.of("crossWorkAllowed", true)));
"work:9001", 10L)).thenReturn(approvedGrant(Map.of("crossWorkAllowed", true)));
SecurityRuntimePermissionFacade.RuntimePermissionEnvelopeIssue issue = facade.issue(request);
@ -157,8 +157,18 @@ class ProjectionSecurityRuntimePermissionFacadeTest extends BaseMockitoUnitTest
}
@Test
void should_failClosedWhenSourceSnapshotIdMissing() {
assertDeniedForScope(missingScopeField("sourceSnapshotId"));
void should_issueWhenScopeOmitsSourceSnapshotId() {
// 修正后 sourceSnapshotId 不再是命中条件grant scope 不带它也应成功签发(能力授权粒度)
// sourceSnapshotId 仍从 request 记入 envelope summary 作审计
TenantContextHolder.setTenantId(100L);
SecurityRuntimePermissionFacade.RuntimePermissionRequest request = approvedRequest();
when(toolGrantMapper.selectRuntimeApprovedGrant(100L, "createAiTask", "aiTask", 9001L,
"work:9001", 10L)).thenReturn(approvedGrant(missingScopeField("sourceSnapshotId")));
SecurityRuntimePermissionFacade.RuntimePermissionEnvelopeIssue issue = facade.issue(request);
assertTrue(issue.runtimePermissionEnvelopeId().startsWith("rpe-local-"));
assertEquals("src-approved", issue.summary().get("sourceSnapshotId"));
}
@Test
@ -171,7 +181,7 @@ class ProjectionSecurityRuntimePermissionFacadeTest extends BaseMockitoUnitTest
TenantContextHolder.setTenantId(100L);
SecurityRuntimePermissionFacade.RuntimePermissionRequest request = approvedRequest();
when(toolGrantMapper.selectRuntimeApprovedGrant(100L, "createAiTask", "aiTask", 9001L,
"work:9001", "src-approved", 10L)).thenReturn(approvedGrantWithScope(JsonUtils.toJsonString(Map.of(
"work:9001", 10L)).thenReturn(approvedGrantWithScope(JsonUtils.toJsonString(Map.of(
"scopeType", "work",
"ownerType", "user",
"resourceRefs", List.of(Map.of("resourceType", "work", "resourceId", "9001"))))));
@ -186,7 +196,7 @@ class ProjectionSecurityRuntimePermissionFacadeTest extends BaseMockitoUnitTest
TenantContextHolder.setTenantId(100L);
SecurityRuntimePermissionFacade.RuntimePermissionRequest request = approvedRequest();
when(toolGrantMapper.selectRuntimeApprovedGrant(100L, "createAiTask", "aiTask", 9001L,
"work:9001", "src-approved", 10L)).thenReturn(approvedGrant(Map.of("crossWorkAllowed", true,
"work:9001", 10L)).thenReturn(approvedGrant(Map.of("crossWorkAllowed", true,
"resourceRefs", List.of(Map.of("resourceType", "work", "resourceId", "9002")))));
ServiceException exception = assertThrows(ServiceException.class, () -> facade.issue(request));
@ -209,7 +219,7 @@ class ProjectionSecurityRuntimePermissionFacadeTest extends BaseMockitoUnitTest
@Test
void should_keepMapperSqlFailClosedForRuntimeScopeFields() throws Exception {
Method method = MuseToolGrantMapper.class.getMethod("selectRuntimeApprovedGrant", Long.class, String.class,
String.class, Long.class, String.class, String.class, String.class, Long.class, String.class);
String.class, Long.class, String.class, String.class, Long.class, String.class);
String sql = String.join("\n", method.getAnnotation(org.apache.ibatis.annotations.Select.class).value());
assertTrue(sql.contains("NULLIF(#{agentIdText}, '') IS NOT NULL"));
@ -217,8 +227,9 @@ class ProjectionSecurityRuntimePermissionFacadeTest extends BaseMockitoUnitTest
assertTrue(sql.contains("scope ->> 'ownerType' IN"));
assertTrue(sql.contains("scope ->> 'operationId' = #{operationId}"));
assertTrue(sql.contains("scope ->> 'targetType' = #{targetType}"));
assertTrue(sql.contains("scope ->> 'sourceSnapshotId' = #{sourceSnapshotId}"));
assertTrue(sql.contains("scope ->> 'agentId' = #{agentIdText}"));
// 修正sourceSnapshotId 已从命中条件剥离SQL 不应再出现该字段(执行证据不参与能力授权命中)
assertEquals(-1, sql.indexOf("sourceSnapshotId"));
assertTrue(sql.contains("(#{targetIdText} IS NOT NULL AND scope ->> 'targetId' = #{targetIdText})"));
assertTrue(sql.contains("(#{targetKey} IS NOT NULL AND scope ->> 'targetKey' = #{targetKey})"));
assertTrue(sql.contains("jsonb_array_length(scope -> 'resourceRefs') > 0"));
@ -228,7 +239,6 @@ class ProjectionSecurityRuntimePermissionFacadeTest extends BaseMockitoUnitTest
assertTrue(sql.contains("LIMIT 1"));
assertEquals(-1, sql.indexOf("NOT jsonb_exists(scope, 'operationId')"));
assertEquals(-1, sql.indexOf("NOT jsonb_exists(scope, 'targetType')"));
assertEquals(-1, sql.indexOf("NOT jsonb_exists(scope, 'sourceSnapshotId')"));
assertEquals(-1, sql.indexOf("NOT jsonb_exists(scope, 'agentId')"));
}
@ -236,7 +246,7 @@ class ProjectionSecurityRuntimePermissionFacadeTest extends BaseMockitoUnitTest
TenantContextHolder.setTenantId(100L);
SecurityRuntimePermissionFacade.RuntimePermissionRequest request = approvedRequest();
when(toolGrantMapper.selectRuntimeApprovedGrant(100L, "createAiTask", "aiTask", 9001L,
"work:9001", "src-approved", 10L)).thenReturn(approvedGrant(overrideScope));
"work:9001", 10L)).thenReturn(approvedGrant(overrideScope));
ServiceException exception = assertThrows(ServiceException.class, () -> facade.issue(request));