mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-05 02:41:49 +00:00
feat(auth): add organization control-plane read APIs
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
5f91069cc2
commit
da317d94e9
14 changed files with 1151 additions and 0 deletions
|
|
@ -0,0 +1,75 @@
|
|||
package com.iflytek.skillhub.controller.portal;
|
||||
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.controller.BaseApiController;
|
||||
import com.iflytek.skillhub.dto.ApiResponse;
|
||||
import com.iflytek.skillhub.dto.ApiResponseFactory;
|
||||
import com.iflytek.skillhub.dto.LoginConnectionSummaryResponse;
|
||||
import com.iflytek.skillhub.dto.OrganizationDetailResponse;
|
||||
import com.iflytek.skillhub.dto.OrganizationSummaryResponse;
|
||||
import com.iflytek.skillhub.dto.PageResponse;
|
||||
import com.iflytek.skillhub.service.OrganizationPortalQueryAppService;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/organizations")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public class OrganizationController extends BaseApiController {
|
||||
|
||||
private final OrganizationPortalQueryAppService organizationQueryAppService;
|
||||
|
||||
public OrganizationController(
|
||||
OrganizationPortalQueryAppService organizationQueryAppService,
|
||||
ApiResponseFactory responseFactory
|
||||
) {
|
||||
super(responseFactory);
|
||||
this.organizationQueryAppService = organizationQueryAppService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ApiResponse<PageResponse<OrganizationSummaryResponse>> listOrganizations(
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal
|
||||
) {
|
||||
return ok(
|
||||
"response.success.read",
|
||||
organizationQueryAppService.listOrganizations(principal.userId(), page, size)
|
||||
);
|
||||
}
|
||||
|
||||
@GetMapping("/{organizationId}")
|
||||
public ApiResponse<OrganizationDetailResponse> getOrganization(
|
||||
@PathVariable String organizationId,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal
|
||||
) {
|
||||
return ok(
|
||||
"response.success.read",
|
||||
organizationQueryAppService.getOrganization(organizationId, principal.userId())
|
||||
);
|
||||
}
|
||||
|
||||
@GetMapping("/{organizationId}/login-connections")
|
||||
public ApiResponse<PageResponse<LoginConnectionSummaryResponse>> listLoginConnections(
|
||||
@PathVariable String organizationId,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal
|
||||
) {
|
||||
return ok(
|
||||
"response.success.read",
|
||||
organizationQueryAppService.listLoginConnections(
|
||||
organizationId,
|
||||
principal.userId(),
|
||||
page,
|
||||
size
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record LoginConnectionSummaryResponse(
|
||||
String id,
|
||||
String publicHandle,
|
||||
String displayName,
|
||||
String status,
|
||||
String adapterKey,
|
||||
String activeRevisionId,
|
||||
String lastTestedRevisionId,
|
||||
Instant createdAt,
|
||||
Instant updatedAt,
|
||||
long version
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Set;
|
||||
|
||||
public record OrganizationDetailResponse(
|
||||
String id,
|
||||
String slug,
|
||||
String displayName,
|
||||
String status,
|
||||
long authorityVersion,
|
||||
String membershipId,
|
||||
long membershipAuthorityVersion,
|
||||
Set<String> roles,
|
||||
Instant memberSince,
|
||||
Instant createdAt,
|
||||
Instant updatedAt,
|
||||
OrganizationPermissionsResponse permissions
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
public record OrganizationPermissionsResponse(
|
||||
boolean canViewManagement,
|
||||
boolean canViewRoles,
|
||||
boolean canViewDomains,
|
||||
boolean canViewMembers,
|
||||
boolean canViewLoginConnections,
|
||||
boolean canManageLoginConnections,
|
||||
boolean canRotateLoginSecrets,
|
||||
boolean canManageMembers,
|
||||
boolean canViewAudit
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Set;
|
||||
|
||||
public record OrganizationSummaryResponse(
|
||||
String id,
|
||||
String slug,
|
||||
String displayName,
|
||||
String status,
|
||||
long authorityVersion,
|
||||
String membershipId,
|
||||
long membershipAuthorityVersion,
|
||||
Set<String> roles,
|
||||
Instant memberSince,
|
||||
Instant updatedAt,
|
||||
OrganizationPermissionsResponse permissions
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,222 @@
|
|||
package com.iflytek.skillhub.repository;
|
||||
|
||||
import com.iflytek.skillhub.domain.organization.OrganizationRoleBindingStatus;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.PersistenceContext;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public class JpaOrganizationQueryRepository implements OrganizationQueryRepository {
|
||||
|
||||
@PersistenceContext
|
||||
private EntityManager entityManager;
|
||||
|
||||
@Override
|
||||
public Page<OrganizationMembershipProjection> findActiveMembershipsForUser(
|
||||
String userId,
|
||||
Pageable pageable
|
||||
) {
|
||||
long total = ((Number) entityManager.createNativeQuery("""
|
||||
SELECT COUNT(*)
|
||||
FROM organization organization
|
||||
JOIN organization_membership membership
|
||||
ON membership.organization_id = organization.id
|
||||
WHERE membership.user_id = :userId
|
||||
AND membership.status = 'ACTIVE'
|
||||
AND organization.status = 'ACTIVE'
|
||||
""")
|
||||
.setParameter("userId", userId)
|
||||
.getSingleResult()).longValue();
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Object[]> rows = entityManager.createNativeQuery("""
|
||||
SELECT organization.id,
|
||||
organization.slug,
|
||||
organization.display_name,
|
||||
organization.status,
|
||||
organization.authority_version,
|
||||
membership.id,
|
||||
membership.authority_version,
|
||||
membership.activated_at,
|
||||
organization.created_at,
|
||||
organization.updated_at
|
||||
FROM organization organization
|
||||
JOIN organization_membership membership
|
||||
ON membership.organization_id = organization.id
|
||||
WHERE membership.user_id = :userId
|
||||
AND membership.status = 'ACTIVE'
|
||||
AND organization.status = 'ACTIVE'
|
||||
ORDER BY organization.slug ASC, organization.id ASC
|
||||
""")
|
||||
.setParameter("userId", userId)
|
||||
.setFirstResult((int) pageable.getOffset())
|
||||
.setMaxResults(pageable.getPageSize())
|
||||
.getResultList();
|
||||
|
||||
return new PageImpl<>(
|
||||
rows.stream().map(this::toOrganizationMembership).toList(),
|
||||
pageable,
|
||||
total
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<OrganizationMembershipProjection> findActiveMembership(
|
||||
String organizationId,
|
||||
String userId
|
||||
) {
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Object[]> rows = entityManager.createNativeQuery("""
|
||||
SELECT organization.id,
|
||||
organization.slug,
|
||||
organization.display_name,
|
||||
organization.status,
|
||||
organization.authority_version,
|
||||
membership.id,
|
||||
membership.authority_version,
|
||||
membership.activated_at,
|
||||
organization.created_at,
|
||||
organization.updated_at
|
||||
FROM organization organization
|
||||
JOIN organization_membership membership
|
||||
ON membership.organization_id = organization.id
|
||||
WHERE organization.id = :organizationId
|
||||
AND membership.user_id = :userId
|
||||
AND membership.status = 'ACTIVE'
|
||||
AND organization.status = 'ACTIVE'
|
||||
""")
|
||||
.setParameter("organizationId", organizationId)
|
||||
.setParameter("userId", userId)
|
||||
.setMaxResults(1)
|
||||
.getResultList();
|
||||
return rows.stream().findFirst().map(this::toOrganizationMembership);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Set<String>> findActiveRoleNames(
|
||||
String userId,
|
||||
Collection<String> organizationIds
|
||||
) {
|
||||
if (organizationIds == null || organizationIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
List<Object[]> rows = entityManager.createQuery("""
|
||||
SELECT binding.organizationId, binding.role
|
||||
FROM OrganizationRoleBinding binding
|
||||
WHERE binding.userId = :userId
|
||||
AND binding.status = :status
|
||||
AND binding.organizationId IN :organizationIds
|
||||
ORDER BY binding.organizationId ASC, binding.role ASC
|
||||
""", Object[].class)
|
||||
.setParameter("userId", userId)
|
||||
.setParameter("status", OrganizationRoleBindingStatus.ACTIVE)
|
||||
.setParameter("organizationIds", organizationIds)
|
||||
.getResultList();
|
||||
Map<String, Set<String>> grouped = rows.stream().collect(Collectors.groupingBy(
|
||||
row -> (String) row[0],
|
||||
LinkedHashMap::new,
|
||||
Collectors.mapping(row -> ((Enum<?>) row[1]).name(), Collectors.toUnmodifiableSet())
|
||||
));
|
||||
return Map.copyOf(grouped);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Page<LoginConnectionSummaryProjection> findLoginConnections(
|
||||
String organizationId,
|
||||
Pageable pageable
|
||||
) {
|
||||
long total = ((Number) entityManager.createNativeQuery("""
|
||||
SELECT COUNT(*)
|
||||
FROM login_connection
|
||||
WHERE organization_id = :organizationId
|
||||
""")
|
||||
.setParameter("organizationId", organizationId)
|
||||
.getSingleResult()).longValue();
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Object[]> rows = entityManager.createNativeQuery("""
|
||||
SELECT id,
|
||||
public_handle,
|
||||
display_name,
|
||||
status,
|
||||
adapter_key,
|
||||
active_revision_id,
|
||||
last_tested_revision_id,
|
||||
created_at,
|
||||
updated_at,
|
||||
version
|
||||
FROM login_connection
|
||||
WHERE organization_id = :organizationId
|
||||
ORDER BY created_at DESC, id ASC
|
||||
""")
|
||||
.setParameter("organizationId", organizationId)
|
||||
.setFirstResult((int) pageable.getOffset())
|
||||
.setMaxResults(pageable.getPageSize())
|
||||
.getResultList();
|
||||
|
||||
return new PageImpl<>(
|
||||
rows.stream().map(this::toLoginConnection).toList(),
|
||||
pageable,
|
||||
total
|
||||
);
|
||||
}
|
||||
|
||||
private OrganizationMembershipProjection toOrganizationMembership(Object[] row) {
|
||||
return new OrganizationMembershipProjection(
|
||||
(String) row[0],
|
||||
(String) row[1],
|
||||
(String) row[2],
|
||||
(String) row[3],
|
||||
((Number) row[4]).longValue(),
|
||||
(String) row[5],
|
||||
((Number) row[6]).longValue(),
|
||||
instant(row[7]),
|
||||
instant(row[8]),
|
||||
instant(row[9])
|
||||
);
|
||||
}
|
||||
|
||||
private LoginConnectionSummaryProjection toLoginConnection(Object[] row) {
|
||||
return new LoginConnectionSummaryProjection(
|
||||
(String) row[0],
|
||||
(String) row[1],
|
||||
(String) row[2],
|
||||
(String) row[3],
|
||||
(String) row[4],
|
||||
(String) row[5],
|
||||
(String) row[6],
|
||||
instant(row[7]),
|
||||
instant(row[8]),
|
||||
((Number) row[9]).longValue()
|
||||
);
|
||||
}
|
||||
|
||||
private Instant instant(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value instanceof Instant instant) {
|
||||
return instant;
|
||||
}
|
||||
if (value instanceof Timestamp timestamp) {
|
||||
return timestamp.toInstant();
|
||||
}
|
||||
if (value instanceof OffsetDateTime offsetDateTime) {
|
||||
return offsetDateTime.toInstant();
|
||||
}
|
||||
throw new IllegalArgumentException("unsupported timestamp type: " + value.getClass());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
package com.iflytek.skillhub.repository;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Collection;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
public interface OrganizationQueryRepository {
|
||||
|
||||
Page<OrganizationMembershipProjection> findActiveMembershipsForUser(
|
||||
String userId,
|
||||
Pageable pageable
|
||||
);
|
||||
|
||||
Optional<OrganizationMembershipProjection> findActiveMembership(
|
||||
String organizationId,
|
||||
String userId
|
||||
);
|
||||
|
||||
Map<String, Set<String>> findActiveRoleNames(
|
||||
String userId,
|
||||
Collection<String> organizationIds
|
||||
);
|
||||
|
||||
Page<LoginConnectionSummaryProjection> findLoginConnections(
|
||||
String organizationId,
|
||||
Pageable pageable
|
||||
);
|
||||
|
||||
record OrganizationMembershipProjection(
|
||||
String id,
|
||||
String slug,
|
||||
String displayName,
|
||||
String status,
|
||||
long authorityVersion,
|
||||
String membershipId,
|
||||
long membershipAuthorityVersion,
|
||||
Instant memberSince,
|
||||
Instant createdAt,
|
||||
Instant updatedAt
|
||||
) {
|
||||
}
|
||||
|
||||
record LoginConnectionSummaryProjection(
|
||||
String id,
|
||||
String publicHandle,
|
||||
String displayName,
|
||||
String status,
|
||||
String adapterKey,
|
||||
String activeRevisionId,
|
||||
String lastTestedRevisionId,
|
||||
Instant createdAt,
|
||||
Instant updatedAt,
|
||||
long version
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
import com.iflytek.skillhub.domain.organization.OrganizationAdministrativeAction;
|
||||
import com.iflytek.skillhub.domain.organization.OrganizationAuthorizationPolicy;
|
||||
import com.iflytek.skillhub.domain.organization.OrganizationAuthorizationService;
|
||||
import com.iflytek.skillhub.domain.organization.OrganizationRole;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
|
||||
import com.iflytek.skillhub.dto.LoginConnectionSummaryResponse;
|
||||
import com.iflytek.skillhub.dto.OrganizationDetailResponse;
|
||||
import com.iflytek.skillhub.dto.OrganizationPermissionsResponse;
|
||||
import com.iflytek.skillhub.dto.OrganizationSummaryResponse;
|
||||
import com.iflytek.skillhub.dto.PageResponse;
|
||||
import com.iflytek.skillhub.repository.OrganizationQueryRepository;
|
||||
import com.iflytek.skillhub.repository.OrganizationQueryRepository.OrganizationMembershipProjection;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class OrganizationPortalQueryAppService {
|
||||
|
||||
private static final int MAX_PAGE_SIZE = 100;
|
||||
|
||||
private final OrganizationQueryRepository organizationQueryRepository;
|
||||
private final OrganizationAuthorizationService authorizationService;
|
||||
private final OrganizationAuthorizationPolicy authorizationPolicy;
|
||||
|
||||
public OrganizationPortalQueryAppService(
|
||||
OrganizationQueryRepository organizationQueryRepository,
|
||||
OrganizationAuthorizationService authorizationService,
|
||||
OrganizationAuthorizationPolicy authorizationPolicy
|
||||
) {
|
||||
this.organizationQueryRepository = organizationQueryRepository;
|
||||
this.authorizationService = authorizationService;
|
||||
this.authorizationPolicy = authorizationPolicy;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResponse<OrganizationSummaryResponse> listOrganizations(
|
||||
String userId,
|
||||
int page,
|
||||
int size
|
||||
) {
|
||||
Pageable pageable = pageRequest(page, size);
|
||||
var memberships = organizationQueryRepository.findActiveMembershipsForUser(
|
||||
userId,
|
||||
pageable
|
||||
);
|
||||
Map<String, Set<String>> roleNames = organizationQueryRepository.findActiveRoleNames(
|
||||
userId,
|
||||
memberships.stream().map(OrganizationMembershipProjection::id).toList()
|
||||
);
|
||||
return PageResponse.from(memberships.map(membership ->
|
||||
toSummary(membership, roleNames.getOrDefault(membership.id(), Set.of()))));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public OrganizationDetailResponse getOrganization(String organizationId, String userId) {
|
||||
authorizationService.requireAllowed(
|
||||
organizationId,
|
||||
userId,
|
||||
OrganizationAdministrativeAction.VIEW_ORGANIZATION
|
||||
);
|
||||
OrganizationMembershipProjection membership = organizationQueryRepository
|
||||
.findActiveMembership(organizationId, userId)
|
||||
.orElseThrow(() -> new DomainForbiddenException(
|
||||
"error.organization.permission.denied"
|
||||
));
|
||||
Set<String> roles = organizationQueryRepository
|
||||
.findActiveRoleNames(userId, Set.of(organizationId))
|
||||
.getOrDefault(organizationId, Set.of());
|
||||
return toDetail(membership, roles);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResponse<LoginConnectionSummaryResponse> listLoginConnections(
|
||||
String organizationId,
|
||||
String userId,
|
||||
int page,
|
||||
int size
|
||||
) {
|
||||
authorizationService.requireAllowed(
|
||||
organizationId,
|
||||
userId,
|
||||
OrganizationAdministrativeAction.VIEW_LOGIN_CONNECTIONS
|
||||
);
|
||||
return PageResponse.from(organizationQueryRepository
|
||||
.findLoginConnections(organizationId, pageRequest(page, size))
|
||||
.map(connection -> new LoginConnectionSummaryResponse(
|
||||
connection.id(),
|
||||
connection.publicHandle(),
|
||||
connection.displayName(),
|
||||
connection.status(),
|
||||
connection.adapterKey(),
|
||||
connection.activeRevisionId(),
|
||||
connection.lastTestedRevisionId(),
|
||||
connection.createdAt(),
|
||||
connection.updatedAt(),
|
||||
connection.version()
|
||||
)));
|
||||
}
|
||||
|
||||
private OrganizationSummaryResponse toSummary(
|
||||
OrganizationMembershipProjection membership,
|
||||
Set<String> roleNames
|
||||
) {
|
||||
Set<OrganizationRole> roles = roles(roleNames);
|
||||
return new OrganizationSummaryResponse(
|
||||
membership.id(),
|
||||
membership.slug(),
|
||||
membership.displayName(),
|
||||
membership.status(),
|
||||
membership.authorityVersion(),
|
||||
membership.membershipId(),
|
||||
membership.membershipAuthorityVersion(),
|
||||
roleNames(roleNames),
|
||||
membership.memberSince(),
|
||||
membership.updatedAt(),
|
||||
permissions(roles)
|
||||
);
|
||||
}
|
||||
|
||||
private OrganizationDetailResponse toDetail(
|
||||
OrganizationMembershipProjection membership,
|
||||
Set<String> roleNames
|
||||
) {
|
||||
Set<OrganizationRole> roles = roles(roleNames);
|
||||
return new OrganizationDetailResponse(
|
||||
membership.id(),
|
||||
membership.slug(),
|
||||
membership.displayName(),
|
||||
membership.status(),
|
||||
membership.authorityVersion(),
|
||||
membership.membershipId(),
|
||||
membership.membershipAuthorityVersion(),
|
||||
roleNames(roleNames),
|
||||
membership.memberSince(),
|
||||
membership.createdAt(),
|
||||
membership.updatedAt(),
|
||||
permissions(roles)
|
||||
);
|
||||
}
|
||||
|
||||
private OrganizationPermissionsResponse permissions(Set<OrganizationRole> roles) {
|
||||
return new OrganizationPermissionsResponse(
|
||||
authorizationPolicy.isAllowed(roles, OrganizationAdministrativeAction.VIEW_ORGANIZATION),
|
||||
authorizationPolicy.isAllowed(roles, OrganizationAdministrativeAction.VIEW_ORGANIZATION_ROLES),
|
||||
authorizationPolicy.isAllowed(roles, OrganizationAdministrativeAction.VIEW_DOMAINS),
|
||||
authorizationPolicy.isAllowed(roles, OrganizationAdministrativeAction.VIEW_MEMBERS),
|
||||
authorizationPolicy.isAllowed(roles, OrganizationAdministrativeAction.VIEW_LOGIN_CONNECTIONS),
|
||||
authorizationPolicy.isAllowed(roles, OrganizationAdministrativeAction.MANAGE_LOGIN_CONNECTIONS),
|
||||
authorizationPolicy.isAllowed(roles, OrganizationAdministrativeAction.ROTATE_LOGIN_SECRETS),
|
||||
authorizationPolicy.isAllowed(roles, OrganizationAdministrativeAction.MANAGE_MEMBERS),
|
||||
authorizationPolicy.isAllowed(roles, OrganizationAdministrativeAction.VIEW_AUDIT)
|
||||
);
|
||||
}
|
||||
|
||||
private Set<OrganizationRole> roles(Collection<String> roleNames) {
|
||||
return roleNames.stream()
|
||||
.map(role -> OrganizationRole.valueOf(role.toUpperCase(Locale.ROOT)))
|
||||
.collect(Collectors.toUnmodifiableSet());
|
||||
}
|
||||
|
||||
private Set<String> roleNames(Collection<String> roleNames) {
|
||||
return roleNames.stream()
|
||||
.sorted(Comparator.naturalOrder())
|
||||
.collect(Collectors.toUnmodifiableSet());
|
||||
}
|
||||
|
||||
private Pageable pageRequest(int page, int size) {
|
||||
if (page < 0 || size < 1) {
|
||||
throw new DomainBadRequestException("error.pagination.invalid");
|
||||
}
|
||||
return PageRequest.of(page, Math.min(size, MAX_PAGE_SIZE));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,234 @@
|
|||
package com.iflytek.skillhub.controller.portal;
|
||||
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.iflytek.skillhub.auth.device.DeviceAuthService;
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.domain.organization.MembershipSourceType;
|
||||
import com.iflytek.skillhub.domain.organization.Organization;
|
||||
import com.iflytek.skillhub.domain.organization.OrganizationMembership;
|
||||
import com.iflytek.skillhub.domain.organization.OrganizationRole;
|
||||
import com.iflytek.skillhub.domain.organization.OrganizationRoleBinding;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles("test")
|
||||
@Transactional
|
||||
class OrganizationControllerTest {
|
||||
|
||||
private static final Instant T0 = Instant.parse("2026-09-22T00:00:00Z");
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private EntityManager entityManager;
|
||||
|
||||
@MockBean
|
||||
private DeviceAuthService deviceAuthService;
|
||||
|
||||
@Test
|
||||
void listOrganizations_returnsOnlyActiveMembershipsForCurrentUser() throws Exception {
|
||||
TestOrganization memberOrg = createOrganization("alpha-team", "Alpha Team", "member");
|
||||
createRole(memberOrg.id(), "member", OrganizationRole.MEMBER_ADMIN);
|
||||
createOrganization("other-team", "Other Team", "other");
|
||||
|
||||
mockMvc.perform(get("/api/v1/organizations")
|
||||
.with(authentication(auth("member"))))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.total").value(1))
|
||||
.andExpect(jsonPath("$.data.items[0].id").value(memberOrg.id()))
|
||||
.andExpect(jsonPath("$.data.items[0].slug").value("alpha-team"))
|
||||
.andExpect(jsonPath("$.data.items[0].roles[0]").value("MEMBER_ADMIN"))
|
||||
.andExpect(jsonPath("$.data.items[0].permissions.canManageMembers").value(true))
|
||||
.andExpect(jsonPath("$.data.items[0].permissions.canManageLoginConnections").value(false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listOrganizations_keepsMemberOnlyOrganizationButWithoutManagementEntry() throws Exception {
|
||||
createOrganization("member-only", "Member Only", "member");
|
||||
|
||||
mockMvc.perform(get("/api/v1/organizations")
|
||||
.with(authentication(auth("member"))))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.total").value(1))
|
||||
.andExpect(jsonPath("$.data.items[0].slug").value("member-only"))
|
||||
.andExpect(jsonPath("$.data.items[0].roles").isEmpty())
|
||||
.andExpect(jsonPath("$.data.items[0].permissions.canViewManagement").value(false))
|
||||
.andExpect(jsonPath("$.data.items[0].permissions.canViewLoginConnections").value(false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listOrganizations_rejectsInvalidPagination() throws Exception {
|
||||
mockMvc.perform(get("/api/v1/organizations")
|
||||
.param("page", "-1")
|
||||
.with(authentication(auth("member"))))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value(400));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getOrganization_rejectsPlatformAdminWhoIsNotTenantMember() throws Exception {
|
||||
TestOrganization organization = createOrganization("tenant-a", "Tenant A", "member");
|
||||
|
||||
mockMvc.perform(get("/api/v1/organizations/{organizationId}", organization.id())
|
||||
.with(authentication(auth("platform-admin", "SUPER_ADMIN"))))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.code").value(403));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listLoginConnections_returnsSanitizedConnectionSummaryForIdentityAdmin() throws Exception {
|
||||
TestOrganization organization = createOrganization("identity-team", "Identity Team", "member");
|
||||
createRole(organization.id(), "member", OrganizationRole.IDENTITY_ADMIN);
|
||||
createLoginConnection(organization.id(), "conn-1", "handle001", "Corporate OIDC");
|
||||
|
||||
mockMvc.perform(get("/api/v1/organizations/{organizationId}/login-connections", organization.id())
|
||||
.with(authentication(auth("member"))))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.total").value(1))
|
||||
.andExpect(jsonPath("$.data.items[0].id").value("conn-1"))
|
||||
.andExpect(jsonPath("$.data.items[0].publicHandle").value("handle001"))
|
||||
.andExpect(jsonPath("$.data.items[0].displayName").value("Corporate OIDC"))
|
||||
.andExpect(jsonPath("$.data.items[0].adapterKey").value("oidc"))
|
||||
.andExpect(jsonPath("$.data.items[0].status").value("DRAFT"))
|
||||
.andExpect(jsonPath("$.data.items[0].typedConfig").doesNotExist())
|
||||
.andExpect(jsonPath("$.data.items[0].secretBindingVersion").doesNotExist());
|
||||
}
|
||||
|
||||
@Test
|
||||
void listLoginConnections_rejectsMemberWithoutConnectionViewRole() throws Exception {
|
||||
TestOrganization organization = createOrganization("plain-member", "Plain Member", "member");
|
||||
createLoginConnection(organization.id(), "conn-2", "handle002", "Corporate OIDC");
|
||||
|
||||
mockMvc.perform(get("/api/v1/organizations/{organizationId}/login-connections", organization.id())
|
||||
.with(authentication(auth("member"))))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.code").value(403));
|
||||
}
|
||||
|
||||
private TestOrganization createOrganization(String slug, String displayName, String memberUserId) {
|
||||
persistUser("creator");
|
||||
persistUser(memberUserId);
|
||||
Organization organization = Organization.create(slug, displayName, "creator", T0);
|
||||
entityManager.persist(organization);
|
||||
OrganizationMembership membership = OrganizationMembership.provisioned(
|
||||
organization.getId(),
|
||||
MembershipSourceType.MANUAL,
|
||||
"manual-" + memberUserId + "-" + slug,
|
||||
memberUserId,
|
||||
memberUserId + "@example.com",
|
||||
T0
|
||||
);
|
||||
membership.activate(memberUserId, T0.plusSeconds(1));
|
||||
entityManager.persist(membership);
|
||||
entityManager.flush();
|
||||
return new TestOrganization(organization.getId(), slug);
|
||||
}
|
||||
|
||||
private void createRole(String organizationId, String userId, OrganizationRole role) {
|
||||
OrganizationRoleBinding binding = OrganizationRoleBinding.grant(
|
||||
organizationId,
|
||||
userId,
|
||||
role,
|
||||
"creator",
|
||||
T0.plusSeconds(2)
|
||||
);
|
||||
entityManager.persist(binding);
|
||||
entityManager.flush();
|
||||
}
|
||||
|
||||
private void createLoginConnection(
|
||||
String organizationId,
|
||||
String id,
|
||||
String publicHandle,
|
||||
String displayName
|
||||
) {
|
||||
entityManager.createNativeQuery("""
|
||||
INSERT INTO login_connection (
|
||||
id,
|
||||
public_handle,
|
||||
scope_type,
|
||||
organization_id,
|
||||
display_name,
|
||||
status,
|
||||
adapter_key,
|
||||
created_by,
|
||||
created_at,
|
||||
updated_at,
|
||||
version
|
||||
)
|
||||
VALUES (
|
||||
:id,
|
||||
:publicHandle,
|
||||
'ORGANIZATION',
|
||||
:organizationId,
|
||||
:displayName,
|
||||
'DRAFT',
|
||||
'oidc',
|
||||
'creator',
|
||||
:createdAt,
|
||||
:updatedAt,
|
||||
0
|
||||
)
|
||||
""")
|
||||
.setParameter("id", id)
|
||||
.setParameter("publicHandle", publicHandle)
|
||||
.setParameter("organizationId", organizationId)
|
||||
.setParameter("displayName", displayName)
|
||||
.setParameter("createdAt", T0.plusSeconds(3))
|
||||
.setParameter("updatedAt", T0.plusSeconds(3))
|
||||
.executeUpdate();
|
||||
entityManager.flush();
|
||||
}
|
||||
|
||||
private void persistUser(String userId) {
|
||||
if (entityManager.find(UserAccount.class, userId) == null) {
|
||||
entityManager.persist(new UserAccount(
|
||||
userId,
|
||||
userId,
|
||||
userId + "@example.com",
|
||||
""
|
||||
));
|
||||
entityManager.flush();
|
||||
}
|
||||
}
|
||||
|
||||
private UsernamePasswordAuthenticationToken auth(String userId, String... roles) {
|
||||
Set<String> platformRoles = Set.of(roles);
|
||||
PlatformPrincipal principal = new PlatformPrincipal(
|
||||
userId,
|
||||
userId,
|
||||
userId + "@example.com",
|
||||
"",
|
||||
"github",
|
||||
platformRoles
|
||||
);
|
||||
List<SimpleGrantedAuthority> authorities = platformRoles.stream()
|
||||
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
|
||||
.toList();
|
||||
return new UsernamePasswordAuthenticationToken(principal, null, authorities);
|
||||
}
|
||||
|
||||
private record TestOrganization(String id, String slug) {
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
package com.iflytek.skillhub.auth.connection.persistence;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.Version;
|
||||
import java.time.Instant;
|
||||
|
||||
/** Persistent control-plane row for one platform or organization login connection. */
|
||||
@Entity
|
||||
@Table(name = "login_connection")
|
||||
public class LoginConnection {
|
||||
|
||||
@Id
|
||||
@Column(length = 64)
|
||||
private String id;
|
||||
|
||||
@Column(name = "public_handle", nullable = false, length = 128)
|
||||
private String publicHandle;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "scope_type", nullable = false, length = 32)
|
||||
private LoginConnectionScopeType scopeType;
|
||||
|
||||
@Column(name = "organization_id", length = 64)
|
||||
private String organizationId;
|
||||
|
||||
@Column(name = "system_key", length = 128)
|
||||
private String systemKey;
|
||||
|
||||
@Column(name = "display_name", nullable = false, length = 128)
|
||||
private String displayName;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 32)
|
||||
private LoginConnectionStatus status;
|
||||
|
||||
@Column(name = "adapter_key", nullable = false, length = 128)
|
||||
private String adapterKey;
|
||||
|
||||
@Column(name = "active_revision_id", length = 64)
|
||||
private String activeRevisionId;
|
||||
|
||||
@Column(name = "last_tested_revision_id", length = 64)
|
||||
private String lastTestedRevisionId;
|
||||
|
||||
@Column(name = "created_by", length = 128)
|
||||
private String createdBy;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private Instant updatedAt;
|
||||
|
||||
@Version
|
||||
@Column(nullable = false)
|
||||
private long version;
|
||||
|
||||
protected LoginConnection() {
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.iflytek.skillhub.auth.connection.persistence;
|
||||
|
||||
public enum LoginConnectionScopeType {
|
||||
PLATFORM,
|
||||
ORGANIZATION
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.iflytek.skillhub.auth.connection.persistence;
|
||||
|
||||
public enum LoginConnectionStatus {
|
||||
DRAFT,
|
||||
ACTIVE,
|
||||
SUSPENDED,
|
||||
DISABLED
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
/** Persistent login connection control-plane model. */
|
||||
package com.iflytek.skillhub.auth.connection.persistence;
|
||||
225
web/src/api/generated/schema.d.ts
vendored
225
web/src/api/generated/schema.d.ts
vendored
|
|
@ -4336,6 +4336,54 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/organizations": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["listOrganizations"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/organizations/{organizationId}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getOrganization"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/organizations/{organizationId}/login-connections": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["listLoginConnections"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/health": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -7066,6 +7114,113 @@ export interface components {
|
|||
match?: components["schemas"]["VersionInfo"];
|
||||
latestVersion?: components["schemas"]["VersionInfo"];
|
||||
};
|
||||
ApiResponsePageResponseOrganizationSummaryResponse: {
|
||||
/** Format: int32 */
|
||||
code?: number;
|
||||
msg?: string;
|
||||
data?: components["schemas"]["PageResponseOrganizationSummaryResponse"];
|
||||
/** Format: date-time */
|
||||
timestamp?: string;
|
||||
requestId?: string;
|
||||
};
|
||||
OrganizationPermissionsResponse: {
|
||||
canViewManagement?: boolean;
|
||||
canViewRoles?: boolean;
|
||||
canViewDomains?: boolean;
|
||||
canViewMembers?: boolean;
|
||||
canViewLoginConnections?: boolean;
|
||||
canManageLoginConnections?: boolean;
|
||||
canRotateLoginSecrets?: boolean;
|
||||
canManageMembers?: boolean;
|
||||
canViewAudit?: boolean;
|
||||
};
|
||||
OrganizationSummaryResponse: {
|
||||
id?: string;
|
||||
slug?: string;
|
||||
displayName?: string;
|
||||
status?: string;
|
||||
/** Format: int64 */
|
||||
authorityVersion?: number;
|
||||
membershipId?: string;
|
||||
/** Format: int64 */
|
||||
membershipAuthorityVersion?: number;
|
||||
roles?: string[];
|
||||
/** Format: date-time */
|
||||
memberSince?: string;
|
||||
/** Format: date-time */
|
||||
updatedAt?: string;
|
||||
permissions?: components["schemas"]["OrganizationPermissionsResponse"];
|
||||
};
|
||||
PageResponseOrganizationSummaryResponse: {
|
||||
items?: components["schemas"]["OrganizationSummaryResponse"][];
|
||||
/** Format: int64 */
|
||||
total?: number;
|
||||
/** Format: int32 */
|
||||
page?: number;
|
||||
/** Format: int32 */
|
||||
size?: number;
|
||||
};
|
||||
ApiResponseOrganizationDetailResponse: {
|
||||
/** Format: int32 */
|
||||
code?: number;
|
||||
msg?: string;
|
||||
data?: components["schemas"]["OrganizationDetailResponse"];
|
||||
/** Format: date-time */
|
||||
timestamp?: string;
|
||||
requestId?: string;
|
||||
};
|
||||
OrganizationDetailResponse: {
|
||||
id?: string;
|
||||
slug?: string;
|
||||
displayName?: string;
|
||||
status?: string;
|
||||
/** Format: int64 */
|
||||
authorityVersion?: number;
|
||||
membershipId?: string;
|
||||
/** Format: int64 */
|
||||
membershipAuthorityVersion?: number;
|
||||
roles?: string[];
|
||||
/** Format: date-time */
|
||||
memberSince?: string;
|
||||
/** Format: date-time */
|
||||
createdAt?: string;
|
||||
/** Format: date-time */
|
||||
updatedAt?: string;
|
||||
permissions?: components["schemas"]["OrganizationPermissionsResponse"];
|
||||
};
|
||||
ApiResponsePageResponseLoginConnectionSummaryResponse: {
|
||||
/** Format: int32 */
|
||||
code?: number;
|
||||
msg?: string;
|
||||
data?: components["schemas"]["PageResponseLoginConnectionSummaryResponse"];
|
||||
/** Format: date-time */
|
||||
timestamp?: string;
|
||||
requestId?: string;
|
||||
};
|
||||
LoginConnectionSummaryResponse: {
|
||||
id?: string;
|
||||
publicHandle?: string;
|
||||
displayName?: string;
|
||||
status?: string;
|
||||
adapterKey?: string;
|
||||
activeRevisionId?: string;
|
||||
lastTestedRevisionId?: string;
|
||||
/** Format: date-time */
|
||||
createdAt?: string;
|
||||
/** Format: date-time */
|
||||
updatedAt?: string;
|
||||
/** Format: int64 */
|
||||
version?: number;
|
||||
};
|
||||
PageResponseLoginConnectionSummaryResponse: {
|
||||
items?: components["schemas"]["LoginConnectionSummaryResponse"][];
|
||||
/** Format: int64 */
|
||||
total?: number;
|
||||
/** Format: int32 */
|
||||
page?: number;
|
||||
/** Format: int32 */
|
||||
size?: number;
|
||||
};
|
||||
ApiResponseListAuthProviderResponse: {
|
||||
/** Format: int32 */
|
||||
code?: number;
|
||||
|
|
@ -14934,6 +15089,76 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
listOrganizations: {
|
||||
parameters: {
|
||||
query?: {
|
||||
page?: number;
|
||||
size?: number;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ApiResponsePageResponseOrganizationSummaryResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getOrganization: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
organizationId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ApiResponseOrganizationDetailResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
listLoginConnections: {
|
||||
parameters: {
|
||||
query?: {
|
||||
page?: number;
|
||||
size?: number;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
organizationId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ApiResponsePageResponseLoginConnectionSummaryResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
health: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue