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:
parent
1cf2ab389a
commit
d647f0c8d9
@ -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"));
|
||||
|
||||
@ -27,6 +27,11 @@ public interface MuseToolGrantMapper extends BaseMapperX<MuseToolGrantDO> {
|
||||
* <p>P1R-4 还没有接入 Security 主流程 RPC,只能消费已落库的 ToolGrant 投影。查询必须固定
|
||||
* security owner、approved、active 三个条件;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));
|
||||
}
|
||||
|
||||
|
||||
@ -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"));
|
||||
|
||||
@ -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));
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user