mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 03:55:09 +08:00
fix(goal): lock authenticated HTTP identity through managed operations
This commit is contained in:
parent
e1e25d41b6
commit
e8a13b8d20
@ -18,48 +18,51 @@ public class GoalJsonAcceptanceController {
|
||||
|
||||
@GetMapping
|
||||
public R<GoalJsonAcceptanceService.View> get(@PathVariable Long goalId, Authentication auth) {
|
||||
return R.ok(acceptance.get(goalId, username(auth)));
|
||||
return authenticated(auth, username -> acceptance.get(goalId, username));
|
||||
}
|
||||
|
||||
@PutMapping("/requirements/{criterionKey}")
|
||||
public R<GoalJsonAcceptanceService.Requirement> configure(@PathVariable Long goalId, @PathVariable String criterionKey,
|
||||
@RequestBody GoalJsonAcceptanceService.ConfigureRequest request, Authentication auth) {
|
||||
return R.ok(acceptance.configure(goalId, criterionKey, request, username(auth)));
|
||||
return authenticated(auth, username -> acceptance.configure(goalId, criterionKey, request, username));
|
||||
}
|
||||
|
||||
@GetMapping("/artifacts")
|
||||
public R<java.util.List<ManagedGoalJsonService.Slot>> artifacts(@PathVariable Long goalId, Authentication auth) {
|
||||
return R.ok(artifacts.list(goalId, username(auth)));
|
||||
return authenticated(auth, username -> artifacts.list(goalId, username));
|
||||
}
|
||||
|
||||
@PostMapping("/artifacts/{slot}")
|
||||
public R<ManagedGoalJsonService.Artifact> publish(@PathVariable Long goalId, @PathVariable String slot,
|
||||
@RequestBody ManagedGoalJsonService.PublishRequest request, Authentication auth) {
|
||||
return R.ok(artifacts.publish(goalId, slot, request, username(auth)));
|
||||
return authenticated(auth, username -> artifacts.publish(goalId, slot, request, username));
|
||||
}
|
||||
|
||||
@GetMapping("/artifacts/versions/{artifactId}")
|
||||
public R<ManagedGoalJsonService.Content> version(@PathVariable Long goalId, @PathVariable String artifactId, Authentication auth) {
|
||||
return R.ok(artifacts.read(goalId, artifactId, username(auth)));
|
||||
return authenticated(auth, username -> artifacts.read(goalId, artifactId, username));
|
||||
}
|
||||
|
||||
@GetMapping("/snapshot")
|
||||
public R<vip.mate.goal.service.GoalJsonBindingService.Snapshot> snapshot(@PathVariable Long goalId, Authentication auth) {
|
||||
return R.ok(bindings.snapshot(goalId, username(auth)));
|
||||
return authenticated(auth, username -> bindings.snapshot(goalId, username));
|
||||
}
|
||||
|
||||
@GetMapping("/checks")
|
||||
public R<java.util.List<vip.mate.goal.service.GoalJsonBindingService.State>> checks(@PathVariable Long goalId, Authentication auth) {
|
||||
return R.ok(bindings.state(goalId, username(auth)));
|
||||
return authenticated(auth, username -> bindings.state(goalId, username));
|
||||
}
|
||||
|
||||
@PostMapping("/checks/{criterionKey}")
|
||||
public R<vip.mate.goal.service.GoalJsonBindingService.Check> check(@PathVariable Long goalId, @PathVariable String criterionKey,
|
||||
@RequestBody vip.mate.goal.service.GoalJsonBindingService.CheckRequest request, Authentication auth) {
|
||||
return R.ok(bindings.check(goalId, criterionKey, request, username(auth)));
|
||||
return authenticated(auth, username -> bindings.check(goalId, criterionKey, request, username));
|
||||
}
|
||||
|
||||
private static String username(Authentication auth) {
|
||||
return auth != null && auth.isAuthenticated() ? auth.getName() : null;
|
||||
private <T> R<T> authenticated(Authentication auth, java.util.function.Function<String, T> operation) {
|
||||
if (auth == null || !auth.isAuthenticated() || !(auth.getDetails() instanceof Long userId)) {
|
||||
throw new vip.mate.exception.MateClawException(401, "Authenticated account ID required");
|
||||
}
|
||||
return R.ok(acceptance.withAuthenticatedUser(userId, auth.getName(), operation));
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,6 +28,15 @@ public class GoalJsonAcceptanceService {
|
||||
public record View(boolean required, String status, List<Requirement> requirements) { }
|
||||
record GoalScope(long id, String conversationId, long workspaceId, long agentId, String status, boolean required) { }
|
||||
|
||||
/** Keep the HTTP caller's immutable identity locked through the complete managed operation. */
|
||||
@Transactional
|
||||
public <T> T withAuthenticatedUser(Long userId, String username, java.util.function.Function<String, T> operation) {
|
||||
if (userId == null || username == null || username.isBlank()) throw failure(401, "Authenticated account ID required");
|
||||
List<String> names = jdbc.queryForList("SELECT username FROM mate_user WHERE id=? AND enabled=TRUE AND deleted=0 FOR UPDATE", String.class, userId);
|
||||
if (names.size() != 1 || !username.equals(names.getFirst())) throw failure(403, "Authenticated account is no longer current");
|
||||
return operation.apply(names.getFirst());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public View get(Long goalId, String username) {
|
||||
GoalScope goal = authorizedGoal(goalId, username, true);
|
||||
|
||||
@ -63,4 +63,4 @@ From V199, queued Web input stores the authenticated account ID at enqueue time,
|
||||
|
||||
Approval replay restores the persisted runtime identity; approval does not renew an expired attempt lease or override account revocation. Legacy snapshots without an authenticated account ID cannot gain managed JSON access from a display username alone.
|
||||
|
||||
JWT requests match the signed userId to the current enabled account ID. Recreating an account with the same username does not let the old token modify managed requirements or acquire the new runtime identity. A missing or malformed ID requires a fresh login. Sliding renewal retains the validated account identity.
|
||||
JWT requests match the signed userId to the current enabled account ID. Recreating an account with the same username does not let the old token modify managed requirements or acquire the new runtime identity. A missing or malformed ID requires a fresh login. Sliding renewal retains the validated account identity. Managed HTTP operations also lock and recheck the authenticated account ID inside their transaction, retaining that lock until the read or write finishes. A username alone or an in-flight identity whose account was replaced cannot access these endpoints.
|
||||
|
||||
@ -65,4 +65,4 @@ Web排队消息从V199起保存入队时已认证账户的内部ID,普通Web
|
||||
|
||||
审批重放还原持久化的运行身份,但审批不会延长过期 attempt 租约,也不能覆盖账户撤权。缺少认证账户 ID 的旧快照不能仅凭显示用户名获得托管 JSON 权限。
|
||||
|
||||
JWT请求同时核对签名令牌的userId与当前启用账户ID。同名账户重新创建后,旧账户令牌不能修改托管要求或取得新账户的运行身份;缺失或格式错误的ID需要重新登录。滑动续期沿用已验证的账户身份。
|
||||
JWT请求同时核对签名令牌的userId与当前启用账户ID。同名账户重新创建后,旧账户令牌不能修改托管要求或取得新账户的运行身份;缺失或格式错误的ID需要重新登录。滑动续期沿用已验证的账户身份。 托管HTTP操作还会在事务内按认证账户ID加锁复查,并持锁至读取或修改结束;仅有用户名或已被替换的在途身份不能访问这些接口。
|
||||
|
||||
@ -35,6 +35,7 @@ class GoalJsonAcceptanceIntegrationTest {
|
||||
@MockBean private MemoryManager memory;
|
||||
@Autowired private GoalService goals;
|
||||
@Autowired private GoalJsonAcceptanceService acceptance;
|
||||
@Autowired private vip.mate.goal.controller.GoalJsonAcceptanceController jsonController;
|
||||
@Autowired private vip.mate.goal.service.ManagedGoalJsonService artifacts;
|
||||
@Autowired private JdbcTemplate jdbc;
|
||||
@Autowired private PlatformTransactionManager transactions;
|
||||
@ -97,6 +98,37 @@ class GoalJsonAcceptanceIntegrationTest {
|
||||
assertNull(goals.findActiveByConversation(paused.getConversationId()), "History must not revive an inactive goal");
|
||||
}
|
||||
|
||||
@ParameterizedTest @ValueSource(strings = {"reassigned", "missing"})
|
||||
void managedHttpBoundaryRechecksTheAuthenticatedAccountInsideItsTransaction(String kind) {
|
||||
GoalEntity goal = goal(false);
|
||||
Long originalId = jdbc.queryForObject("SELECT id FROM mate_user WHERE username=?", Long.class, alice);
|
||||
var auth = new org.springframework.security.authentication.UsernamePasswordAuthenticationToken(alice, null,
|
||||
List.of(new org.springframework.security.core.authority.SimpleGrantedAuthority("ROLE_USER")));
|
||||
auth.setDetails(originalId);
|
||||
jsonController.configure(goal.getId(), "r", request(0, "summary"), auth);
|
||||
var version = artifacts.publish(goal.getId(), "report", publication(0, "{\"summary\":false}"), alice);
|
||||
bindings.check(goal.getId(), "r", checkRequest(1, version), alice);
|
||||
if (kind.equals("reassigned")) {
|
||||
// An in-flight request passed authentication before the account was replaced.
|
||||
jdbc.update("UPDATE mate_user SET username=?,deleted=1,enabled=FALSE WHERE id=?", "retired-" + originalId, originalId);
|
||||
jdbc.update("INSERT INTO mate_user(id,username,password,enabled,role,create_time,update_time,deleted) VALUES (?,?,?,TRUE,'user',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,0)",
|
||||
IdWorker.getId(), alice, "unused-test-password");
|
||||
} else auth.setDetails(null);
|
||||
assertAll(
|
||||
() -> assertThrows(MateClawException.class, () -> jsonController.get(goal.getId(), auth)),
|
||||
() -> assertThrows(MateClawException.class, () -> jsonController.artifacts(goal.getId(), auth)),
|
||||
() -> assertThrows(MateClawException.class, () -> jsonController.version(goal.getId(), version.artifactId(), auth)),
|
||||
() -> assertThrows(MateClawException.class, () -> jsonController.snapshot(goal.getId(), auth)),
|
||||
() -> assertThrows(MateClawException.class, () -> jsonController.checks(goal.getId(), auth)),
|
||||
() -> assertThrows(MateClawException.class, () -> jsonController.check(goal.getId(), "r", checkRequest(1, version), auth)),
|
||||
() -> assertThrows(MateClawException.class, () -> jsonController.configure(goal.getId(), "r", request(1, "summary"), auth)),
|
||||
() -> assertThrows(MateClawException.class, () -> jsonController.publish(goal.getId(), "report", publication(1, "{\"summary\":0}"), auth)));
|
||||
assertEquals(1, bindings.snapshot(goal.getId(), alice).versionCount());
|
||||
assertEquals(1, acceptance.get(goal.getId(), alice).requirements().getFirst().revision());
|
||||
auth.setDetails(jdbc.queryForObject("SELECT id FROM mate_user WHERE username=? AND deleted=0", Long.class, alice));
|
||||
assertTrue(jsonController.get(goal.getId(), auth).getData().required());
|
||||
}
|
||||
|
||||
@Test void ownerCanPersistAndReviseRequirementsWithoutAcceptingAStaleEdit() {
|
||||
GoalEntity goal = goal(false);
|
||||
assertFalse(acceptance.get(goal.getId(), alice).required());
|
||||
|
||||
Loading…
Reference in New Issue
Block a user