feat(approval): REST surface for auto-grant strategies with tiered authorization

This commit is contained in:
matevip 2026-05-27 14:07:56 +08:00
parent 2653356613
commit fe072191ea
3 changed files with 695 additions and 3 deletions

View File

@ -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 (看全部) 需要 adminmine=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;
}
}

View File

@ -109,15 +109,33 @@ public class AuthService {
* 修改密码
*/
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);
if (user == null) {
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", "原密码错误");
}
user.setPassword(passwordEncoder.encode(newPassword));
userMapper.updateById(user);
}
/**

View File

@ -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;
}
}