fix(namespace): let super admin view namespaces

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-08-11 14:22:37 +08:00
parent 76d95b615e
commit c99df875f2
5 changed files with 157 additions and 20 deletions

View file

@ -61,23 +61,28 @@ public class NamespaceController extends BaseApiController {
@GetMapping("/namespaces")
public ApiResponse<PageResponse<NamespaceResponse>> listNamespaces(
Pageable pageable,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
return ok("response.success.read", namespacePortalQueryAppService.listNamespaces(pageable, userNsRoles));
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
@AuthenticationPrincipal PlatformPrincipal principal) {
return ok("response.success.read",
namespacePortalQueryAppService.listNamespaces(pageable, userNsRoles, platformRoles(principal)));
}
@GetMapping("/me/namespaces")
public ApiResponse<List<MyNamespaceResponse>> listMyNamespaces(
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
return ok("response.success.read", namespacePortalQueryAppService.listMyNamespaces(userNsRoles));
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
@AuthenticationPrincipal PlatformPrincipal principal) {
return ok("response.success.read",
namespacePortalQueryAppService.listMyNamespaces(userNsRoles, platformRoles(principal)));
}
@GetMapping("/namespaces/{slug}")
public ApiResponse<NamespaceResponse> getNamespace(@PathVariable String slug,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
@AuthenticationPrincipal PlatformPrincipal principal) {
return ok("response.success.read",
namespacePortalQueryAppService.getNamespace(slug, userId, userNsRoles));
namespacePortalQueryAppService.getNamespace(slug, userId, userNsRoles, platformRoles(principal)));
}
@PostMapping("/namespaces")
@ -158,11 +163,14 @@ public class NamespaceController extends BaseApiController {
Pageable pageable,
@RequestAttribute("userId") String userId,
@AuthenticationPrincipal PlatformPrincipal principal) {
Set<String> platformRoles = principal != null && principal.platformRoles() != null
return ok("response.success.read",
namespacePortalQueryAppService.listMembers(slug, pageable, userId, platformRoles(principal)));
}
private Set<String> platformRoles(PlatformPrincipal principal) {
return principal != null && principal.platformRoles() != null
? principal.platformRoles()
: Set.of();
return ok("response.success.read",
namespacePortalQueryAppService.listMembers(slug, pageable, userId, platformRoles));
}
@GetMapping("/namespaces/{slug}/member-candidates")

View file

@ -55,7 +55,14 @@ public class NamespacePortalQueryAppService {
}
@Transactional(readOnly = true)
public PageResponse<NamespaceResponse> listNamespaces(Pageable pageable, Map<Long, NamespaceRole> userNamespaceRoles) {
public PageResponse<NamespaceResponse> listNamespaces(Pageable pageable,
Map<Long, NamespaceRole> userNamespaceRoles,
Set<String> platformRoles) {
if (isSuperAdmin(platformRoles)) {
return PageResponse.from(namespaceRepository.findByStatus(NamespaceStatus.ACTIVE, pageable)
.map(NamespaceResponse::from));
}
Map<Long, NamespaceRole> namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of();
if (namespaceRoles.isEmpty()) {
Page<NamespaceResponse> empty = new PageImpl<>(
@ -83,24 +90,34 @@ public class NamespacePortalQueryAppService {
}
@Transactional(readOnly = true)
public List<MyNamespaceResponse> listMyNamespaces(Map<Long, NamespaceRole> userNamespaceRoles) {
public List<MyNamespaceResponse> listMyNamespaces(Map<Long, NamespaceRole> userNamespaceRoles,
Set<String> platformRoles) {
Map<Long, NamespaceRole> namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of();
if (isSuperAdmin(platformRoles)) {
return namespaceRepository.findAll().stream()
.sorted(Comparator.comparing(Namespace::getSlug))
.map(namespace -> toMyNamespaceResponse(namespace, namespaceRoles.get(namespace.getId())))
.toList();
}
if (namespaceRoles.isEmpty()) {
return List.of();
}
return namespaceRepository.findByIdIn(namespaceRoles.keySet().stream().toList()).stream()
.sorted(Comparator.comparing(Namespace::getSlug))
.map(namespace -> MyNamespaceResponse.from(
namespace,
namespaceRoles.get(namespace.getId()),
namespaceAccessPolicy,
namespaceService.canDelete(namespace, namespaceRoles.get(namespace.getId()))))
.map(namespace -> toMyNamespaceResponse(namespace, namespaceRoles.get(namespace.getId())))
.toList();
}
@Transactional(readOnly = true)
public NamespaceResponse getNamespace(String slug, String userId, Map<Long, NamespaceRole> userNamespaceRoles) {
public NamespaceResponse getNamespace(String slug,
String userId,
Map<Long, NamespaceRole> userNamespaceRoles,
Set<String> platformRoles) {
if (isSuperAdmin(platformRoles)) {
return NamespaceResponse.from(namespaceService.getNamespaceBySlug(slug));
}
Map<Long, NamespaceRole> namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of();
Namespace namespace = namespaceService.getNamespaceBySlugForRead(
slug,
@ -138,4 +155,16 @@ public class NamespacePortalQueryAppService {
MemberResponse.from(member, userMap.get(member.getUserId()))
));
}
private MyNamespaceResponse toMyNamespaceResponse(Namespace namespace, NamespaceRole role) {
return MyNamespaceResponse.from(
namespace,
role,
namespaceAccessPolicy,
role != null && namespaceService.canDelete(namespace, role));
}
private boolean isSuperAdmin(Set<String> platformRoles) {
return platformRoles != null && platformRoles.contains("SUPER_ADMIN");
}
}

View file

@ -95,6 +95,60 @@ class NamespacePortalControllerTest {
.andExpect(jsonPath("$.data[0].canDelete").value(false));
}
@Test
void listMyNamespaces_allowsSuperAdminToSeeAllNamespacesWithoutMembership() throws Exception {
Namespace teamA = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM);
Namespace teamB = namespace(2L, "team-b", NamespaceStatus.ARCHIVED, NamespaceType.TEAM);
given(namespaceRepository.findAll()).willReturn(List.of(teamB, teamA));
given(namespaceMemberRepository.findByUserId("super-1")).willReturn(List.of());
mockMvc.perform(get("/api/v1/me/namespaces")
.with(auth("super-1", Set.of("SUPER_ADMIN")))
.requestAttr("userId", "super-1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data[0].slug").value("team-a"))
.andExpect(jsonPath("$.data[0].currentUserRole").doesNotExist())
.andExpect(jsonPath("$.data[1].slug").value("team-b"))
.andExpect(jsonPath("$.data[1].status").value("ARCHIVED"));
}
@Test
void getNamespace_allowsSuperAdminToReadArchivedNamespaceWithoutMembership() throws Exception {
Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ARCHIVED, NamespaceType.TEAM);
given(namespaceService.getNamespaceBySlug("team-a")).willReturn(namespace);
given(namespaceMemberRepository.findByUserId("super-1")).willReturn(List.of());
mockMvc.perform(get("/api/v1/namespaces/team-a")
.with(auth("super-1", Set.of("SUPER_ADMIN")))
.requestAttr("userId", "super-1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.slug").value("team-a"))
.andExpect(jsonPath("$.data.status").value("ARCHIVED"));
}
@Test
void listNamespaces_allowsSuperAdminToSeeActiveNamespacesWithoutMembership() throws Exception {
Namespace teamA = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM);
given(namespaceRepository.findByStatus(eq(NamespaceStatus.ACTIVE), any()))
.willReturn(new org.springframework.data.domain.PageImpl<>(
List.of(teamA),
org.springframework.data.domain.PageRequest.of(0, 20),
1
));
given(namespaceMemberRepository.findByUserId("super-1")).willReturn(List.of());
mockMvc.perform(get("/api/v1/namespaces")
.param("page", "0")
.param("size", "20")
.with(auth("super-1", Set.of("SUPER_ADMIN")))
.requestAttr("userId", "super-1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.items[0].slug").value("team-a"));
}
@Test
void getNamespace_requiresAuthentication() throws Exception {
mockMvc.perform(get("/api/v1/namespaces/team-a"))

View file

@ -67,7 +67,7 @@ class NamespacePortalQueryAppServiceTest {
var response = service.listMyNamespaces(Map.of(
2L, NamespaceRole.ADMIN,
1L, NamespaceRole.OWNER
));
), Set.of());
assertThat(response).hasSize(2);
assertThat(response.get(0).slug()).isEqualTo("alpha");
@ -93,7 +93,8 @@ class NamespacePortalQueryAppServiceTest {
1L, NamespaceRole.MEMBER,
2L, NamespaceRole.ADMIN,
3L, NamespaceRole.OWNER
)
),
Set.of()
);
assertThat(response.items()).hasSize(2);
@ -107,10 +108,54 @@ class NamespacePortalQueryAppServiceTest {
when(namespaceService.getNamespaceBySlugForRead("team-a", "user-1", Map.of()))
.thenReturn(namespace);
assertThatThrownBy(() -> service.getNamespace("team-a", "user-1", Map.of()))
assertThatThrownBy(() -> service.getNamespace("team-a", "user-1", Map.of(), Set.of()))
.isInstanceOf(DomainForbiddenException.class);
}
@Test
void listNamespaces_superAdminSeesAllActiveNamespacesWithoutMembership() {
Namespace teamA = namespace(1L, "team-a");
Namespace teamB = namespace(2L, "team-b");
when(namespaceRepository.findByStatus(eq(NamespaceStatus.ACTIVE), any(PageRequest.class)))
.thenReturn(new PageImpl<>(List.of(teamA, teamB), PageRequest.of(0, 20), 2));
var response = service.listNamespaces(PageRequest.of(0, 20), Map.of(), Set.of("SUPER_ADMIN"));
assertThat(response.items()).extracting("slug").containsExactly("team-a", "team-b");
}
@Test
void listMyNamespaces_superAdminSeesAllNamespacesWithoutSyntheticOwnerActions() {
Namespace active = namespace(1L, "active-team");
Namespace archived = namespace(2L, "archived-team");
archived.setStatus(NamespaceStatus.ARCHIVED);
when(namespaceRepository.findAll()).thenReturn(List.of(archived, active));
when(namespaceAccessPolicy.isImmutable(active)).thenReturn(false);
when(namespaceAccessPolicy.isImmutable(archived)).thenReturn(false);
var response = service.listMyNamespaces(Map.of(), Set.of("SUPER_ADMIN"));
assertThat(response).hasSize(2);
assertThat(response.get(0).slug()).isEqualTo("active-team");
assertThat(response.get(0).currentUserRole()).isNull();
assertThat(response.get(0).canFreeze()).isFalse();
assertThat(response.get(0).canArchive()).isFalse();
assertThat(response.get(0).canDelete()).isFalse();
assertThat(response.get(1).slug()).isEqualTo("archived-team");
}
@Test
void getNamespace_superAdminReadsNamespaceWithoutMembership() {
Namespace namespace = namespace(1L, "team-a");
namespace.setStatus(NamespaceStatus.ARCHIVED);
when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(namespace);
var response = service.getNamespace("team-a", "super-1", Map.of(), Set.of("SUPER_ADMIN"));
assertThat(response.slug()).isEqualTo("team-a");
assertThat(response.status()).isEqualTo(NamespaceStatus.ARCHIVED);
}
private Namespace namespace(Long id, String slug) {
Namespace namespace = new Namespace(slug, slug, "owner-1");
ReflectionTestUtils.setField(namespace, "id", id);

View file

@ -11,6 +11,7 @@ import java.util.Optional;
*/
public interface NamespaceRepository {
Optional<Namespace> findById(Long id);
List<Namespace> findAll();
List<Namespace> findByIdIn(List<Long> ids);
Optional<Namespace> findBySlug(String slug);
Page<Namespace> findByStatus(NamespaceStatus status, Pageable pageable);