feat(auth): add organization control-plane read APIs

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-09-22 16:38:10 +08:00
parent 5f91069cc2
commit da317d94e9
14 changed files with 1151 additions and 0 deletions

View file

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

View file

@ -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
) {
}

View file

@ -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
) {
}

View file

@ -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
) {
}

View file

@ -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
) {
}

View file

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

View file

@ -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
) {
}
}

View file

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

View file

@ -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) {
}
}

View file

@ -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() {
}
}

View file

@ -0,0 +1,6 @@
package com.iflytek.skillhub.auth.connection.persistence;
public enum LoginConnectionScopeType {
PLATFORM,
ORGANIZATION
}

View file

@ -0,0 +1,8 @@
package com.iflytek.skillhub.auth.connection.persistence;
public enum LoginConnectionStatus {
DRAFT,
ACTIVE,
SUSPENDED,
DISABLED
}

View file

@ -0,0 +1,2 @@
/** Persistent login connection control-plane model. */
package com.iflytek.skillhub.auth.connection.persistence;

View file

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