From da317d94e912bd05d580f5a11e6f9155a7933aa1 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:38:10 +0800 Subject: [PATCH] feat(auth): add organization control-plane read APIs Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../portal/OrganizationController.java | 75 ++++++ .../dto/LoginConnectionSummaryResponse.java | 17 ++ .../dto/OrganizationDetailResponse.java | 20 ++ .../dto/OrganizationPermissionsResponse.java | 14 ++ .../dto/OrganizationSummaryResponse.java | 19 ++ .../JpaOrganizationQueryRepository.java | 222 +++++++++++++++++ .../OrganizationQueryRepository.java | 60 +++++ .../OrganizationPortalQueryAppService.java | 184 ++++++++++++++ .../portal/OrganizationControllerTest.java | 234 ++++++++++++++++++ .../persistence/LoginConnection.java | 65 +++++ .../persistence/LoginConnectionScopeType.java | 6 + .../persistence/LoginConnectionStatus.java | 8 + .../connection/persistence/package-info.java | 2 + web/src/api/generated/schema.d.ts | 225 +++++++++++++++++ 14 files changed, 1151 insertions(+) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/OrganizationController.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LoginConnectionSummaryResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/OrganizationDetailResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/OrganizationPermissionsResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/OrganizationSummaryResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaOrganizationQueryRepository.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/OrganizationQueryRepository.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/OrganizationPortalQueryAppService.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/OrganizationControllerTest.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/persistence/LoginConnection.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/persistence/LoginConnectionScopeType.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/persistence/LoginConnectionStatus.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/persistence/package-info.java diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/OrganizationController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/OrganizationController.java new file mode 100644 index 00000000..16574fcb --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/OrganizationController.java @@ -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> 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 getOrganization( + @PathVariable String organizationId, + @AuthenticationPrincipal PlatformPrincipal principal + ) { + return ok( + "response.success.read", + organizationQueryAppService.getOrganization(organizationId, principal.userId()) + ); + } + + @GetMapping("/{organizationId}/login-connections") + public ApiResponse> 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 + ) + ); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LoginConnectionSummaryResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LoginConnectionSummaryResponse.java new file mode 100644 index 00000000..d9186f6a --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LoginConnectionSummaryResponse.java @@ -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 +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/OrganizationDetailResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/OrganizationDetailResponse.java new file mode 100644 index 00000000..740e8fe6 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/OrganizationDetailResponse.java @@ -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 roles, + Instant memberSince, + Instant createdAt, + Instant updatedAt, + OrganizationPermissionsResponse permissions +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/OrganizationPermissionsResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/OrganizationPermissionsResponse.java new file mode 100644 index 00000000..5ed8d1a8 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/OrganizationPermissionsResponse.java @@ -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 +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/OrganizationSummaryResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/OrganizationSummaryResponse.java new file mode 100644 index 00000000..bbd905dd --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/OrganizationSummaryResponse.java @@ -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 roles, + Instant memberSince, + Instant updatedAt, + OrganizationPermissionsResponse permissions +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaOrganizationQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaOrganizationQueryRepository.java new file mode 100644 index 00000000..a271ab7d --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaOrganizationQueryRepository.java @@ -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 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 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 findActiveMembership( + String organizationId, + String userId + ) { + @SuppressWarnings("unchecked") + List 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> findActiveRoleNames( + String userId, + Collection organizationIds + ) { + if (organizationIds == null || organizationIds.isEmpty()) { + return Map.of(); + } + List 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> 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 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 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()); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/OrganizationQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/OrganizationQueryRepository.java new file mode 100644 index 00000000..a2b0045f --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/OrganizationQueryRepository.java @@ -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 findActiveMembershipsForUser( + String userId, + Pageable pageable + ); + + Optional findActiveMembership( + String organizationId, + String userId + ); + + Map> findActiveRoleNames( + String userId, + Collection organizationIds + ); + + Page 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 + ) { + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/OrganizationPortalQueryAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/OrganizationPortalQueryAppService.java new file mode 100644 index 00000000..c7d60908 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/OrganizationPortalQueryAppService.java @@ -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 listOrganizations( + String userId, + int page, + int size + ) { + Pageable pageable = pageRequest(page, size); + var memberships = organizationQueryRepository.findActiveMembershipsForUser( + userId, + pageable + ); + Map> 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 roles = organizationQueryRepository + .findActiveRoleNames(userId, Set.of(organizationId)) + .getOrDefault(organizationId, Set.of()); + return toDetail(membership, roles); + } + + @Transactional(readOnly = true) + public PageResponse 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 roleNames + ) { + Set 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 roleNames + ) { + Set 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 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 roles(Collection roleNames) { + return roleNames.stream() + .map(role -> OrganizationRole.valueOf(role.toUpperCase(Locale.ROOT))) + .collect(Collectors.toUnmodifiableSet()); + } + + private Set roleNames(Collection 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)); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/OrganizationControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/OrganizationControllerTest.java new file mode 100644 index 00000000..72693ff1 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/OrganizationControllerTest.java @@ -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 platformRoles = Set.of(roles); + PlatformPrincipal principal = new PlatformPrincipal( + userId, + userId, + userId + "@example.com", + "", + "github", + platformRoles + ); + List authorities = platformRoles.stream() + .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) + .toList(); + return new UsernamePasswordAuthenticationToken(principal, null, authorities); + } + + private record TestOrganization(String id, String slug) { + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/persistence/LoginConnection.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/persistence/LoginConnection.java new file mode 100644 index 00000000..4e4b8bc6 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/persistence/LoginConnection.java @@ -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() { + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/persistence/LoginConnectionScopeType.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/persistence/LoginConnectionScopeType.java new file mode 100644 index 00000000..1e6ef783 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/persistence/LoginConnectionScopeType.java @@ -0,0 +1,6 @@ +package com.iflytek.skillhub.auth.connection.persistence; + +public enum LoginConnectionScopeType { + PLATFORM, + ORGANIZATION +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/persistence/LoginConnectionStatus.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/persistence/LoginConnectionStatus.java new file mode 100644 index 00000000..7b2d4d26 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/persistence/LoginConnectionStatus.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.auth.connection.persistence; + +public enum LoginConnectionStatus { + DRAFT, + ACTIVE, + SUSPENDED, + DISABLED +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/persistence/package-info.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/persistence/package-info.java new file mode 100644 index 00000000..6a05d93e --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/persistence/package-info.java @@ -0,0 +1,2 @@ +/** Persistent login connection control-plane model. */ +package com.iflytek.skillhub.auth.connection.persistence; diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index d756bff0..2ee22f04 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -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;