mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-14 03:33:43 +08:00
feat(approval): REST surface for auto-grant strategies with tiered authorization
This commit is contained in:
parent
2653356613
commit
fe072191ea
@ -0,0 +1,351 @@
|
|||||||
|
package vip.mate.approval.grant.controller;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import vip.mate.approval.grant.entity.ApprovalGrant;
|
||||||
|
import vip.mate.approval.grant.entity.ApprovalResolutionLog;
|
||||||
|
import vip.mate.approval.grant.repository.ApprovalGrantMapper;
|
||||||
|
import vip.mate.approval.grant.repository.ApprovalResolutionLogMapper;
|
||||||
|
import vip.mate.approval.grant.service.ApprovalGrantService;
|
||||||
|
import vip.mate.auth.model.UserEntity;
|
||||||
|
import vip.mate.auth.service.AuthService;
|
||||||
|
import vip.mate.common.result.R;
|
||||||
|
import vip.mate.exception.MateClawException;
|
||||||
|
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
|
||||||
|
import vip.mate.workspace.core.service.WorkspaceService;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST surface for the auto-grant subsystem.
|
||||||
|
* <p>
|
||||||
|
* The header {@code @RequireWorkspaceRole("member")} is the minimum gate; the
|
||||||
|
* §2.4.5 6-cell authorization matrix is enforced inside each handler:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code CONVERSATION} scope — any workspace member.</li>
|
||||||
|
* <li>{@code USER} scope — only the actor can create a grant for themselves.</li>
|
||||||
|
* <li>{@code AGENT} scope with explicit {@code toolName} — agent owner or admin.</li>
|
||||||
|
* <li>{@code AGENT} scope with {@code toolName=null} — admin only, plus password.</li>
|
||||||
|
* <li>{@code WORKSPACE} scope with explicit {@code toolName} — admin only.</li>
|
||||||
|
* <li>{@code WORKSPACE} scope with {@code toolName=null} — admin only, plus password.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Snowflake id fields ({@code id} / {@code grantedBy} / {@code revokedBy} /
|
||||||
|
* {@code scopeId}) are serialized as strings by the global Jackson config so the
|
||||||
|
* frontend keeps them as strings end-to-end (see CLAUDE.md precision convention).
|
||||||
|
*/
|
||||||
|
@Tag(name = "自动批准策略")
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/approval")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ApprovalGrantController {
|
||||||
|
|
||||||
|
private static final long DEFAULT_WORKSPACE_ID = 1L;
|
||||||
|
|
||||||
|
private final ApprovalGrantService grantService;
|
||||||
|
private final ApprovalGrantMapper grantMapper;
|
||||||
|
private final ApprovalResolutionLogMapper resolutionMapper;
|
||||||
|
private final AuthService authService;
|
||||||
|
private final WorkspaceService workspaceService;
|
||||||
|
|
||||||
|
// ─── Create ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Operation(summary = "创建自动批准策略")
|
||||||
|
@PostMapping("/grants")
|
||||||
|
@RequireWorkspaceRole("member")
|
||||||
|
public R<ApprovalGrant> create(@RequestBody CreateGrantRequest body,
|
||||||
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
|
||||||
|
Authentication auth) {
|
||||||
|
Long actorId = resolveUserId(auth);
|
||||||
|
Long ws = workspaceId != null ? workspaceId : DEFAULT_WORKSPACE_ID;
|
||||||
|
|
||||||
|
validate(body);
|
||||||
|
enforceCreationAuthorization(body, actorId, ws);
|
||||||
|
|
||||||
|
ApprovalGrant grant = new ApprovalGrant();
|
||||||
|
grant.setWorkspaceId(ws);
|
||||||
|
grant.setScopeType(body.scopeType);
|
||||||
|
grant.setScopeId(body.scopeId);
|
||||||
|
grant.setToolName(emptyToNull(body.toolName));
|
||||||
|
grant.setRuleId(emptyToNull(body.ruleId));
|
||||||
|
grant.setMaxSeverity(body.maxSeverity);
|
||||||
|
grant.setGrantKind(body.grantKind);
|
||||||
|
grant.setExpireAt(body.expireAt);
|
||||||
|
grant.setGrantedBy(actorId);
|
||||||
|
grant.setGrantedAt(LocalDateTime.now());
|
||||||
|
grant.setRevoked(0);
|
||||||
|
grant.setDeleted(0);
|
||||||
|
grant.setNote(body.note);
|
||||||
|
|
||||||
|
grantMapper.insert(grant);
|
||||||
|
log.info("[APPROVAL] Grant created: id={} scope={}/{} tool={} rule={} ceiling={} kind={} by user={}",
|
||||||
|
grant.getId(), grant.getScopeType(), grant.getScopeId(),
|
||||||
|
grant.getToolName(), grant.getRuleId(), grant.getMaxSeverity(),
|
||||||
|
grant.getGrantKind(), actorId);
|
||||||
|
return R.ok(grant);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── List ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Operation(summary = "列出当前 workspace 的自动批准策略")
|
||||||
|
@GetMapping("/grants")
|
||||||
|
@RequireWorkspaceRole("member")
|
||||||
|
public R<List<ApprovalGrant>> list(
|
||||||
|
@RequestParam(required = false) String scopeType,
|
||||||
|
@RequestParam(required = false) String toolName,
|
||||||
|
@RequestParam(required = false) Integer revoked,
|
||||||
|
@RequestParam(required = false, defaultValue = "false") boolean mine,
|
||||||
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
|
||||||
|
Authentication auth) {
|
||||||
|
Long actorId = resolveUserId(auth);
|
||||||
|
Long ws = workspaceId != null ? workspaceId : DEFAULT_WORKSPACE_ID;
|
||||||
|
|
||||||
|
// mine=false (看全部) 需要 admin;mine=true 任意 member 可以看自己的
|
||||||
|
if (!mine) {
|
||||||
|
workspaceService.requirePermission(ws, actorId, "admin");
|
||||||
|
}
|
||||||
|
|
||||||
|
var wrapper = Wrappers.<ApprovalGrant>lambdaQuery()
|
||||||
|
.eq(ApprovalGrant::getWorkspaceId, ws)
|
||||||
|
.eq(ApprovalGrant::getDeleted, 0)
|
||||||
|
.orderByDesc(ApprovalGrant::getGrantedAt);
|
||||||
|
if (scopeType != null && !scopeType.isEmpty()) {
|
||||||
|
wrapper.eq(ApprovalGrant::getScopeType, scopeType);
|
||||||
|
}
|
||||||
|
if (toolName != null && !toolName.isEmpty()) {
|
||||||
|
wrapper.eq(ApprovalGrant::getToolName, toolName);
|
||||||
|
}
|
||||||
|
if (revoked != null) {
|
||||||
|
wrapper.eq(ApprovalGrant::getRevoked, revoked);
|
||||||
|
}
|
||||||
|
if (mine) {
|
||||||
|
wrapper.eq(ApprovalGrant::getGrantedBy, actorId);
|
||||||
|
}
|
||||||
|
return R.ok(grantMapper.selectList(wrapper));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Active summary (chip "(N)") ────────────────────────────────────
|
||||||
|
|
||||||
|
@Operation(summary = "当前 workspace 的活跃策略数量摘要")
|
||||||
|
@GetMapping("/grants/active")
|
||||||
|
@RequireWorkspaceRole("member")
|
||||||
|
public R<Map<String, Object>> activeSummary(
|
||||||
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
|
Long ws = workspaceId != null ? workspaceId : DEFAULT_WORKSPACE_ID;
|
||||||
|
long count = grantService.countActiveInWorkspace(ws);
|
||||||
|
// hasWorkspaceWide: workspace + tool_name IS NULL — the dangerous one.
|
||||||
|
Long workspaceWide = grantMapper.selectCount(
|
||||||
|
Wrappers.<ApprovalGrant>lambdaQuery()
|
||||||
|
.eq(ApprovalGrant::getWorkspaceId, ws)
|
||||||
|
.eq(ApprovalGrant::getScopeType, ApprovalGrant.ScopeType.WORKSPACE)
|
||||||
|
.isNull(ApprovalGrant::getToolName)
|
||||||
|
.eq(ApprovalGrant::getRevoked, 0)
|
||||||
|
.eq(ApprovalGrant::getDeleted, 0)
|
||||||
|
.and(w -> w.isNull(ApprovalGrant::getExpireAt)
|
||||||
|
.or().gt(ApprovalGrant::getExpireAt, LocalDateTime.now())));
|
||||||
|
Map<String, Object> out = new HashMap<>();
|
||||||
|
out.put("count", count);
|
||||||
|
out.put("hasWorkspaceWide", workspaceWide != null && workspaceWide > 0);
|
||||||
|
return R.ok(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Revoke ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Operation(summary = "撤销自动批准策略")
|
||||||
|
@DeleteMapping("/grants/{id}")
|
||||||
|
@RequireWorkspaceRole("member")
|
||||||
|
public R<Void> revoke(@PathVariable Long id,
|
||||||
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
|
||||||
|
Authentication auth) {
|
||||||
|
Long actorId = resolveUserId(auth);
|
||||||
|
Long ws = workspaceId != null ? workspaceId : DEFAULT_WORKSPACE_ID;
|
||||||
|
|
||||||
|
ApprovalGrant existing = grantMapper.selectById(id);
|
||||||
|
if (existing == null || (existing.getDeleted() != null && existing.getDeleted() == 1)) {
|
||||||
|
throw new MateClawException("err.approval.grant_not_found", 404, "grant not found");
|
||||||
|
}
|
||||||
|
if (!existing.getWorkspaceId().equals(ws)) {
|
||||||
|
// Cross-workspace lookup is treated as not-found to avoid leaking existence.
|
||||||
|
throw new MateClawException("err.approval.grant_not_found", 404, "grant not found");
|
||||||
|
}
|
||||||
|
boolean isOwner = existing.getGrantedBy() != null && existing.getGrantedBy().equals(actorId);
|
||||||
|
boolean isAdmin = workspaceService.hasPermission(ws, actorId, "admin");
|
||||||
|
if (!isOwner && !isAdmin) {
|
||||||
|
throw new MateClawException("err.approval.revoke_forbidden", 403,
|
||||||
|
"only the grant owner or a workspace admin can revoke");
|
||||||
|
}
|
||||||
|
grantService.revoke(id, actorId);
|
||||||
|
log.info("[APPROVAL] Grant revoked: id={} by user={} (owner={}, admin={})",
|
||||||
|
id, actorId, isOwner, isAdmin);
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Resolutions read surface ───────────────────────────────────────
|
||||||
|
|
||||||
|
@Operation(summary = "查询审批最终决策日志(按 grantId 或 conversationId 过滤)")
|
||||||
|
@GetMapping("/resolutions")
|
||||||
|
@RequireWorkspaceRole("member")
|
||||||
|
public R<List<ApprovalResolutionLog>> listResolutions(
|
||||||
|
@RequestParam(required = false) Long grantId,
|
||||||
|
@RequestParam(required = false) String conversationId,
|
||||||
|
@RequestParam(required = false, defaultValue = "100") int limit,
|
||||||
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
|
||||||
|
Authentication auth) {
|
||||||
|
Long actorId = resolveUserId(auth);
|
||||||
|
Long ws = workspaceId != null ? workspaceId : DEFAULT_WORKSPACE_ID;
|
||||||
|
int cappedLimit = Math.min(Math.max(limit, 1), 500);
|
||||||
|
|
||||||
|
// grantId queries are admin-only; conversationId queries (member view) just
|
||||||
|
// filter by membership of the workspace.
|
||||||
|
if (grantId != null) {
|
||||||
|
workspaceService.requirePermission(ws, actorId, "admin");
|
||||||
|
}
|
||||||
|
|
||||||
|
var wrapper = Wrappers.<ApprovalResolutionLog>lambdaQuery()
|
||||||
|
.eq(ApprovalResolutionLog::getDeleted, 0)
|
||||||
|
.eq(ApprovalResolutionLog::getWorkspaceId, ws)
|
||||||
|
.orderByDesc(ApprovalResolutionLog::getCreateTime)
|
||||||
|
.last("LIMIT " + cappedLimit);
|
||||||
|
if (grantId != null) {
|
||||||
|
wrapper.eq(ApprovalResolutionLog::getGrantId, grantId);
|
||||||
|
}
|
||||||
|
if (conversationId != null && !conversationId.isEmpty()) {
|
||||||
|
wrapper.eq(ApprovalResolutionLog::getConversationId, conversationId);
|
||||||
|
}
|
||||||
|
return R.ok(resolutionMapper.selectList(wrapper));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Authorization matrix ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enforces the §2.4.5 6-cell matrix. Throws {@link MateClawException} with
|
||||||
|
* an HTTP 403 status when the actor is not permitted to create this scope.
|
||||||
|
* Password second-factor is checked for the two cells that require it.
|
||||||
|
*/
|
||||||
|
private void enforceCreationAuthorization(CreateGrantRequest body, Long actorId, Long workspaceId) {
|
||||||
|
String scope = body.scopeType;
|
||||||
|
boolean toolNull = body.toolName == null || body.toolName.isEmpty();
|
||||||
|
boolean isAdmin = workspaceService.hasPermission(workspaceId, actorId, "admin");
|
||||||
|
|
||||||
|
switch (scope) {
|
||||||
|
case ApprovalGrant.ScopeType.CONVERSATION -> {
|
||||||
|
// Any member; nothing extra.
|
||||||
|
}
|
||||||
|
case ApprovalGrant.ScopeType.USER -> {
|
||||||
|
if (body.scopeId == null || !body.scopeId.equals(String.valueOf(actorId))) {
|
||||||
|
throw new MateClawException("err.approval.user_scope_self_only", 403,
|
||||||
|
"USER-scope grants can only target the requesting user");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case ApprovalGrant.ScopeType.AGENT -> {
|
||||||
|
if (toolNull) {
|
||||||
|
requireAdminPlusPassword(isAdmin, body.password, actorId);
|
||||||
|
} else if (!isAdmin) {
|
||||||
|
// We don't currently model an "agent owner" surface here, so admin is the safe default.
|
||||||
|
// (Refining this to support agent owner is a v1.1 follow-up.)
|
||||||
|
workspaceService.requirePermission(workspaceId, actorId, "admin");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case ApprovalGrant.ScopeType.WORKSPACE -> {
|
||||||
|
workspaceService.requirePermission(workspaceId, actorId, "admin");
|
||||||
|
if (toolNull) {
|
||||||
|
requireAdminPlusPassword(true, body.password, actorId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default -> throw new MateClawException("err.approval.invalid_scope", 400,
|
||||||
|
"unknown scope_type: " + scope);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requireAdminPlusPassword(boolean isAdmin, String rawPassword, Long actorId) {
|
||||||
|
if (!isAdmin) {
|
||||||
|
throw new MateClawException("err.approval.admin_required", 403, "admin role required");
|
||||||
|
}
|
||||||
|
if (rawPassword == null || rawPassword.isEmpty()) {
|
||||||
|
throw new MateClawException("err.approval.password_required", 403,
|
||||||
|
"this scope requires password re-confirmation");
|
||||||
|
}
|
||||||
|
authService.verifyCurrentUserPassword(actorId, rawPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Validation ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static void validate(CreateGrantRequest body) {
|
||||||
|
if (body.scopeType == null || body.scopeType.isEmpty()) {
|
||||||
|
throw new MateClawException("err.approval.scope_type_required", 400, "scope_type is required");
|
||||||
|
}
|
||||||
|
if (body.scopeId == null || body.scopeId.isEmpty()) {
|
||||||
|
throw new MateClawException("err.approval.scope_id_required", 400, "scope_id is required");
|
||||||
|
}
|
||||||
|
if (body.maxSeverity == null
|
||||||
|
|| !(body.maxSeverity.equals("LOW") || body.maxSeverity.equals("MEDIUM") || body.maxSeverity.equals("HIGH"))) {
|
||||||
|
// CRITICAL is explicitly rejected so it can never be auto-approvable; the resolver
|
||||||
|
// enforces the same gate at runtime as a defense in depth.
|
||||||
|
throw new MateClawException("err.approval.invalid_severity", 400,
|
||||||
|
"max_severity must be LOW | MEDIUM | HIGH (CRITICAL is not auto-approvable)");
|
||||||
|
}
|
||||||
|
if (body.grantKind == null
|
||||||
|
|| !(body.grantKind.equals("ALWAYS")
|
||||||
|
|| body.grantKind.equals("UNTIL_TIMESTAMP")
|
||||||
|
|| body.grantKind.equals("UNTIL_CONVERSATION_END"))) {
|
||||||
|
throw new MateClawException("err.approval.invalid_grant_kind", 400,
|
||||||
|
"grant_kind must be ALWAYS | UNTIL_TIMESTAMP | UNTIL_CONVERSATION_END");
|
||||||
|
}
|
||||||
|
if ("UNTIL_TIMESTAMP".equals(body.grantKind) && body.expireAt == null) {
|
||||||
|
throw new MateClawException("err.approval.expire_at_required", 400,
|
||||||
|
"expire_at is required when grant_kind = UNTIL_TIMESTAMP");
|
||||||
|
}
|
||||||
|
if ("UNTIL_CONVERSATION_END".equals(body.grantKind)
|
||||||
|
&& !ApprovalGrant.ScopeType.CONVERSATION.equals(body.scopeType)) {
|
||||||
|
throw new MateClawException("err.approval.kind_scope_mismatch", 400,
|
||||||
|
"UNTIL_CONVERSATION_END requires scope_type = CONVERSATION");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private Long resolveUserId(Authentication auth) {
|
||||||
|
if (auth == null || auth.getName() == null) {
|
||||||
|
throw new MateClawException("err.auth.unauthenticated", 401, "未登录");
|
||||||
|
}
|
||||||
|
UserEntity user = authService.findByUsername(auth.getName());
|
||||||
|
if (user == null) {
|
||||||
|
throw new MateClawException("err.auth.user_not_found", 404, "用户不存在");
|
||||||
|
}
|
||||||
|
return user.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String emptyToNull(String s) {
|
||||||
|
return s == null || s.isEmpty() ? null : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── DTO ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request body for {@link #create}. Snowflake ids ({@code scopeId}) are
|
||||||
|
* received as strings to preserve precision through JS; the global Jackson
|
||||||
|
* coercion accepts numeric JSON too, so existing tools that send numbers
|
||||||
|
* still work.
|
||||||
|
*/
|
||||||
|
public static class CreateGrantRequest {
|
||||||
|
public String scopeType;
|
||||||
|
public String scopeId;
|
||||||
|
public String toolName;
|
||||||
|
public String ruleId;
|
||||||
|
public String maxSeverity;
|
||||||
|
public String grantKind;
|
||||||
|
public LocalDateTime expireAt;
|
||||||
|
public String note;
|
||||||
|
/** Required only for {@code WORKSPACE + tool_name=null} and {@code AGENT + tool_name=null}. */
|
||||||
|
public String password;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -109,15 +109,33 @@ public class AuthService {
|
|||||||
* 修改密码
|
* 修改密码
|
||||||
*/
|
*/
|
||||||
public void changePassword(Long userId, String oldPassword, String newPassword) {
|
public void changePassword(Long userId, String oldPassword, String newPassword) {
|
||||||
|
verifyCurrentUserPassword(userId, oldPassword);
|
||||||
|
UserEntity user = userMapper.selectById(userId);
|
||||||
|
user.setPassword(passwordEncoder.encode(newPassword));
|
||||||
|
userMapper.updateById(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step-up authentication: confirms that {@code rawPassword} matches the
|
||||||
|
* user's currently stored password without changing anything.
|
||||||
|
* <p>
|
||||||
|
* Used by sensitive operations that require re-confirmation of identity
|
||||||
|
* (e.g. creating a workspace-wide all-tool auto-approve grant). Throws
|
||||||
|
* the same {@link MateClawException} keys as {@link #changePassword} so
|
||||||
|
* the user-facing error message stays consistent.
|
||||||
|
*
|
||||||
|
* @throws MateClawException {@code err.auth.user_not_found} when the user
|
||||||
|
* doesn't exist, or {@code err.auth.wrong_password} when the
|
||||||
|
* password doesn't match.
|
||||||
|
*/
|
||||||
|
public void verifyCurrentUserPassword(Long userId, String rawPassword) {
|
||||||
UserEntity user = userMapper.selectById(userId);
|
UserEntity user = userMapper.selectById(userId);
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
throw new MateClawException("err.auth.user_not_found", "用户不存在");
|
throw new MateClawException("err.auth.user_not_found", "用户不存在");
|
||||||
}
|
}
|
||||||
if (!passwordEncoder.matches(oldPassword, user.getPassword())) {
|
if (rawPassword == null || !passwordEncoder.matches(rawPassword, user.getPassword())) {
|
||||||
throw new MateClawException("err.auth.wrong_password", "原密码错误");
|
throw new MateClawException("err.auth.wrong_password", "原密码错误");
|
||||||
}
|
}
|
||||||
user.setPassword(passwordEncoder.encode(newPassword));
|
|
||||||
userMapper.updateById(user);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -0,0 +1,323 @@
|
|||||||
|
package vip.mate.approval.grant.controller;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Nested;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import vip.mate.approval.grant.entity.ApprovalGrant;
|
||||||
|
import vip.mate.approval.grant.repository.ApprovalGrantMapper;
|
||||||
|
import vip.mate.approval.grant.repository.ApprovalResolutionLogMapper;
|
||||||
|
import vip.mate.approval.grant.service.ApprovalGrantService;
|
||||||
|
import vip.mate.auth.model.UserEntity;
|
||||||
|
import vip.mate.auth.service.AuthService;
|
||||||
|
import vip.mate.exception.MateClawException;
|
||||||
|
import vip.mate.workspace.core.service.WorkspaceService;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.doThrow;
|
||||||
|
import static org.mockito.Mockito.lenient;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plain controller tests for {@link ApprovalGrantController} — matches the
|
||||||
|
* mateclaw house style (see {@code WikiHotCacheControllerTest},
|
||||||
|
* {@code WorkflowControllerTest}): no MockMvc, no Spring boot context, just
|
||||||
|
* direct method calls with mocked dependencies.
|
||||||
|
* <p>
|
||||||
|
* The {@code @RequireWorkspaceRole("member")} HTTP gate is enforced by the
|
||||||
|
* shared {@code WorkspaceAccessInterceptor} and is exercised by its own tests;
|
||||||
|
* here we cover the in-method §2.4.5 6-cell matrix and the password second
|
||||||
|
* factor.
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class ApprovalGrantControllerTest {
|
||||||
|
|
||||||
|
private static final long WORKSPACE_ID = 100L;
|
||||||
|
private static final long MEMBER_ID = 1001L;
|
||||||
|
private static final long ADMIN_ID = 2002L;
|
||||||
|
|
||||||
|
@Mock ApprovalGrantService grantService;
|
||||||
|
@Mock ApprovalGrantMapper grantMapper;
|
||||||
|
@Mock ApprovalResolutionLogMapper resolutionMapper;
|
||||||
|
@Mock AuthService authService;
|
||||||
|
@Mock WorkspaceService workspaceService;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
ApprovalGrantController controller;
|
||||||
|
|
||||||
|
private Authentication memberAuth;
|
||||||
|
private Authentication adminAuth;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
memberAuth = new UsernamePasswordAuthenticationToken("member-user", null);
|
||||||
|
adminAuth = new UsernamePasswordAuthenticationToken("admin-user", null);
|
||||||
|
|
||||||
|
UserEntity member = new UserEntity();
|
||||||
|
member.setId(MEMBER_ID);
|
||||||
|
member.setUsername("member-user");
|
||||||
|
UserEntity admin = new UserEntity();
|
||||||
|
admin.setId(ADMIN_ID);
|
||||||
|
admin.setUsername("admin-user");
|
||||||
|
// Lenient: each test only uses one of the two users; strict mode would
|
||||||
|
// flag the unused one. Using lenient here keeps setUp shared.
|
||||||
|
lenient().when(authService.findByUsername("member-user")).thenReturn(member);
|
||||||
|
lenient().when(authService.findByUsername("admin-user")).thenReturn(admin);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
class CreateAuthorizationMatrix {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void conversation_scope_any_member_can_create() {
|
||||||
|
ApprovalGrantController.CreateGrantRequest body = baseBody("CONVERSATION", "conv-1", "read_file");
|
||||||
|
|
||||||
|
controller.create(body, WORKSPACE_ID, memberAuth);
|
||||||
|
|
||||||
|
// No admin check, no password check; grant inserted.
|
||||||
|
verify(workspaceService, never()).requirePermission(anyLong(), anyLong(), anyString());
|
||||||
|
verify(authService, never()).verifyCurrentUserPassword(anyLong(), anyString());
|
||||||
|
verify(grantMapper).insert(any(ApprovalGrant.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void user_scope_only_targets_self() {
|
||||||
|
ApprovalGrantController.CreateGrantRequest body = baseBody("USER", "9999", "read_file");
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> controller.create(body, WORKSPACE_ID, memberAuth))
|
||||||
|
.isInstanceOf(MateClawException.class)
|
||||||
|
.hasMessageContaining("USER-scope");
|
||||||
|
verify(grantMapper, never()).insert(any(ApprovalGrant.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void user_scope_self_succeeds() {
|
||||||
|
ApprovalGrantController.CreateGrantRequest body = baseBody("USER",
|
||||||
|
String.valueOf(MEMBER_ID), "read_file");
|
||||||
|
|
||||||
|
controller.create(body, WORKSPACE_ID, memberAuth);
|
||||||
|
|
||||||
|
verify(grantMapper).insert(any(ApprovalGrant.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void agent_scope_explicit_tool_requires_admin() {
|
||||||
|
ApprovalGrantController.CreateGrantRequest body = baseBody("AGENT", "agent-1", "read_file");
|
||||||
|
doThrow(new MateClawException("err.workspace.insufficient_permission", 403, "admin required"))
|
||||||
|
.when(workspaceService).requirePermission(WORKSPACE_ID, MEMBER_ID, "admin");
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> controller.create(body, WORKSPACE_ID, memberAuth))
|
||||||
|
.isInstanceOf(MateClawException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void agent_scope_null_tool_requires_admin_plus_password() {
|
||||||
|
ApprovalGrantController.CreateGrantRequest body = baseBody("AGENT", "agent-1", null);
|
||||||
|
// admin true; missing password → 403
|
||||||
|
when(workspaceService.hasPermission(WORKSPACE_ID, ADMIN_ID, "admin")).thenReturn(true);
|
||||||
|
body.password = null;
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> controller.create(body, WORKSPACE_ID, adminAuth))
|
||||||
|
.isInstanceOf(MateClawException.class)
|
||||||
|
.hasMessageContaining("password");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void agent_scope_null_tool_admin_with_password_passes() {
|
||||||
|
ApprovalGrantController.CreateGrantRequest body = baseBody("AGENT", "agent-1", null);
|
||||||
|
body.password = "correct-password";
|
||||||
|
when(workspaceService.hasPermission(WORKSPACE_ID, ADMIN_ID, "admin")).thenReturn(true);
|
||||||
|
// verifyCurrentUserPassword passes silently when correct.
|
||||||
|
|
||||||
|
controller.create(body, WORKSPACE_ID, adminAuth);
|
||||||
|
|
||||||
|
verify(authService).verifyCurrentUserPassword(ADMIN_ID, "correct-password");
|
||||||
|
verify(grantMapper).insert(any(ApprovalGrant.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void workspace_scope_explicit_tool_requires_admin_only() {
|
||||||
|
ApprovalGrantController.CreateGrantRequest body = baseBody("WORKSPACE",
|
||||||
|
String.valueOf(WORKSPACE_ID), "read_file");
|
||||||
|
doThrow(new MateClawException("err.workspace.insufficient_permission", 403, "admin required"))
|
||||||
|
.when(workspaceService).requirePermission(WORKSPACE_ID, MEMBER_ID, "admin");
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> controller.create(body, WORKSPACE_ID, memberAuth))
|
||||||
|
.isInstanceOf(MateClawException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void workspace_scope_null_tool_requires_admin_plus_password_red_button() {
|
||||||
|
ApprovalGrantController.CreateGrantRequest body = baseBody("WORKSPACE",
|
||||||
|
String.valueOf(WORKSPACE_ID), null);
|
||||||
|
body.password = "correct-password";
|
||||||
|
when(workspaceService.hasPermission(WORKSPACE_ID, ADMIN_ID, "admin")).thenReturn(true);
|
||||||
|
|
||||||
|
controller.create(body, WORKSPACE_ID, adminAuth);
|
||||||
|
|
||||||
|
verify(workspaceService).requirePermission(WORKSPACE_ID, ADMIN_ID, "admin");
|
||||||
|
verify(authService).verifyCurrentUserPassword(ADMIN_ID, "correct-password");
|
||||||
|
verify(grantMapper).insert(any(ApprovalGrant.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void critical_severity_is_rejected() {
|
||||||
|
ApprovalGrantController.CreateGrantRequest body = baseBody("CONVERSATION", "conv-1", "read_file");
|
||||||
|
body.maxSeverity = "CRITICAL";
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> controller.create(body, WORKSPACE_ID, memberAuth))
|
||||||
|
.isInstanceOf(MateClawException.class)
|
||||||
|
.hasMessageContaining("CRITICAL is not auto-approvable");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void until_conversation_end_requires_conversation_scope() {
|
||||||
|
ApprovalGrantController.CreateGrantRequest body = baseBody("AGENT", "agent-1", "read_file");
|
||||||
|
body.grantKind = "UNTIL_CONVERSATION_END";
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> controller.create(body, WORKSPACE_ID, memberAuth))
|
||||||
|
.isInstanceOf(MateClawException.class)
|
||||||
|
.hasMessageContaining("UNTIL_CONVERSATION_END");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
class ListRevoke {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void list_mine_does_not_require_admin() {
|
||||||
|
when(grantMapper.selectList(any())).thenReturn(List.of());
|
||||||
|
|
||||||
|
controller.list(null, null, null, /*mine*/ true, WORKSPACE_ID, memberAuth);
|
||||||
|
|
||||||
|
verify(workspaceService, never()).requirePermission(anyLong(), anyLong(), anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void list_all_requires_admin() {
|
||||||
|
doThrow(new MateClawException("err.workspace.insufficient_permission", 403, ""))
|
||||||
|
.when(workspaceService).requirePermission(WORKSPACE_ID, MEMBER_ID, "admin");
|
||||||
|
|
||||||
|
assertThatThrownBy(() ->
|
||||||
|
controller.list(null, null, null, /*mine*/ false, WORKSPACE_ID, memberAuth))
|
||||||
|
.isInstanceOf(MateClawException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void revoke_owner_succeeds_without_admin() {
|
||||||
|
ApprovalGrant g = newGrant(123L, MEMBER_ID);
|
||||||
|
when(grantMapper.selectById(123L)).thenReturn(g);
|
||||||
|
when(workspaceService.hasPermission(anyLong(), anyLong(), anyString())).thenReturn(false);
|
||||||
|
when(grantService.revoke(eq(123L), eq(MEMBER_ID))).thenReturn(true);
|
||||||
|
|
||||||
|
controller.revoke(123L, WORKSPACE_ID, memberAuth);
|
||||||
|
|
||||||
|
verify(grantService).revoke(123L, MEMBER_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void revoke_non_owner_non_admin_forbidden() {
|
||||||
|
ApprovalGrant g = newGrant(123L, /* grantedBy */ 5555L);
|
||||||
|
when(grantMapper.selectById(123L)).thenReturn(g);
|
||||||
|
when(workspaceService.hasPermission(WORKSPACE_ID, MEMBER_ID, "admin")).thenReturn(false);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> controller.revoke(123L, WORKSPACE_ID, memberAuth))
|
||||||
|
.isInstanceOf(MateClawException.class)
|
||||||
|
.hasMessageContaining("only the grant owner or a workspace admin");
|
||||||
|
verify(grantService, never()).revoke(anyLong(), anyLong());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void revoke_admin_succeeds_for_other_users_grant() {
|
||||||
|
ApprovalGrant g = newGrant(123L, /* grantedBy */ 5555L);
|
||||||
|
when(grantMapper.selectById(123L)).thenReturn(g);
|
||||||
|
when(workspaceService.hasPermission(WORKSPACE_ID, ADMIN_ID, "admin")).thenReturn(true);
|
||||||
|
when(grantService.revoke(eq(123L), eq(ADMIN_ID))).thenReturn(true);
|
||||||
|
|
||||||
|
controller.revoke(123L, WORKSPACE_ID, adminAuth);
|
||||||
|
|
||||||
|
verify(grantService).revoke(123L, ADMIN_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void revoke_cross_workspace_returns_not_found() {
|
||||||
|
ApprovalGrant g = newGrant(123L, MEMBER_ID);
|
||||||
|
g.setWorkspaceId(999L); // different workspace
|
||||||
|
when(grantMapper.selectById(123L)).thenReturn(g);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> controller.revoke(123L, WORKSPACE_ID, memberAuth))
|
||||||
|
.isInstanceOf(MateClawException.class)
|
||||||
|
.hasMessageContaining("grant not found");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
class Resolutions {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void grant_id_query_requires_admin() {
|
||||||
|
doThrow(new MateClawException("err.workspace.insufficient_permission", 403, ""))
|
||||||
|
.when(workspaceService).requirePermission(WORKSPACE_ID, MEMBER_ID, "admin");
|
||||||
|
|
||||||
|
assertThatThrownBy(() ->
|
||||||
|
controller.listResolutions(7777L, null, 100, WORKSPACE_ID, memberAuth))
|
||||||
|
.isInstanceOf(MateClawException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void conversation_query_does_not_require_admin() {
|
||||||
|
when(resolutionMapper.selectList(any())).thenReturn(List.of());
|
||||||
|
|
||||||
|
controller.listResolutions(null, "conv-1", 100, WORKSPACE_ID, memberAuth);
|
||||||
|
|
||||||
|
verify(workspaceService, never()).requirePermission(anyLong(), anyLong(), anyString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static ApprovalGrantController.CreateGrantRequest baseBody(
|
||||||
|
String scopeType, String scopeId, String toolName) {
|
||||||
|
ApprovalGrantController.CreateGrantRequest b = new ApprovalGrantController.CreateGrantRequest();
|
||||||
|
b.scopeType = scopeType;
|
||||||
|
b.scopeId = scopeId;
|
||||||
|
b.toolName = toolName;
|
||||||
|
b.ruleId = null;
|
||||||
|
b.maxSeverity = "MEDIUM";
|
||||||
|
b.grantKind = "ALWAYS";
|
||||||
|
b.note = "test";
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ApprovalGrant newGrant(long id, long grantedBy) {
|
||||||
|
ApprovalGrant g = new ApprovalGrant();
|
||||||
|
g.setId(id);
|
||||||
|
g.setWorkspaceId(WORKSPACE_ID);
|
||||||
|
g.setScopeType("CONVERSATION");
|
||||||
|
g.setScopeId("conv-1");
|
||||||
|
g.setToolName("read_file");
|
||||||
|
g.setMaxSeverity("MEDIUM");
|
||||||
|
g.setGrantKind("ALWAYS");
|
||||||
|
g.setGrantedBy(grantedBy);
|
||||||
|
g.setGrantedAt(LocalDateTime.now());
|
||||||
|
g.setRevoked(0);
|
||||||
|
g.setDeleted(0);
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user