diff --git a/pom.xml b/pom.xml index 1aadee48..b1695345 100644 --- a/pom.xml +++ b/pom.xml @@ -60,7 +60,7 @@ dev.vality org-management-proto - 1.18-6b1eb95 + 1.19-e5d3e5f dev.vality diff --git a/src/main/java/dev/vality/orgmanager/converter/AdminManagementConverter.java b/src/main/java/dev/vality/orgmanager/converter/AdminManagementConverter.java new file mode 100644 index 00000000..476e3989 --- /dev/null +++ b/src/main/java/dev/vality/orgmanager/converter/AdminManagementConverter.java @@ -0,0 +1,148 @@ +package dev.vality.orgmanager.converter; + +import dev.vality.orgmanagement.Invitation; +import dev.vality.orgmanagement.InvitationStatus; +import dev.vality.orgmanagement.Member; +import dev.vality.orgmanagement.MemberRole; +import dev.vality.orgmanagement.Organization; +import dev.vality.orgmanagement.OrganizationRole; +import dev.vality.orgmanagement.OrganizationStatus; +import dev.vality.orgmanagement.RoleAssignment; +import dev.vality.orgmanagement.RoleScope; +import dev.vality.orgmanager.entity.InvitationEntity; +import dev.vality.orgmanager.entity.MemberEntity; +import dev.vality.orgmanager.entity.MemberRoleEntity; +import dev.vality.orgmanager.entity.OrganizationEntity; +import dev.vality.orgmanager.entity.OrganizationRoleEntity; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Collection; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Objects; + +@Component +public class AdminManagementConverter { + + public Organization toOrganization(OrganizationEntity entity) { + Organization organization = new Organization( + entity.getId(), + entity.getParty(), + entity.getOwner(), + entity.getName(), + formatDateTime(entity.getCreatedAt()), + toOrganizationStatus(entity.getStatus())); + if (entity.getMetadata() != null) { + organization.setMetadata(entity.getMetadata()); + } + return organization; + } + + public Member toMember(MemberEntity entity, String organizationId) { + List roles = collectionOrEmpty(entity.getRoles()).stream() + .filter(MemberRoleEntity::isActive) + .filter(role -> organizationId.equals(role.getOrganizationId())) + .sorted(Comparator.comparing(MemberRoleEntity::getId)) + .map(this::toMemberRole) + .toList(); + Member member = new Member(entity.getId(), roles); + if (entity.getEmail() != null) { + member.setEmail(entity.getEmail()); + } + return member; + } + + public MemberRole toMemberRole(MemberRoleEntity entity) { + MemberRole role = new MemberRole(entity.getId(), entity.getRoleId()); + if (entity.getScopeId() != null) { + role.setScope(toRoleScope(entity)); + } + return role; + } + + public RoleAssignment toRoleAssignment(MemberRoleEntity entity) { + RoleAssignment assignment = new RoleAssignment(entity.getRoleId()); + if (entity.getScopeId() != null) { + assignment.setScope(toRoleScope(entity)); + } + return assignment; + } + + public Invitation toInvitation(InvitationEntity entity) { + List roles = collectionOrEmpty(entity.getInviteeRoles()).stream() + .sorted(Comparator.comparing(MemberRoleEntity::getId)) + .map(this::toRoleAssignment) + .toList(); + Invitation invitation = new Invitation( + entity.getId(), + entity.getOrganizationId(), + formatDateTime(entity.getCreatedAt()), + formatDateTime(entity.getExpiresAt()), + entity.getInviteeContactEmail(), + roles, + effectiveInvitationStatus(entity)); + if (entity.getMetadata() != null) { + invitation.setMetadata(entity.getMetadata()); + } + if (entity.getAcceptedAt() != null) { + invitation.setAcceptedAt(formatDateTime(entity.getAcceptedAt())); + } + if (entity.getAcceptedMemberId() != null) { + invitation.setAcceptedMemberId(entity.getAcceptedMemberId()); + } + if (entity.getRevokedAt() != null) { + invitation.setRevokedAt(formatDateTime(entity.getRevokedAt())); + } + if (entity.getRevocationReason() != null) { + invitation.setRevocationReason(entity.getRevocationReason()); + } + return invitation; + } + + public InvitationStatus effectiveInvitationStatus(InvitationEntity entity) { + InvitationStatus storedStatus = toInvitationStatus(entity.getStatus()); + if (storedStatus == InvitationStatus.pending && entity.isExpired()) { + return InvitationStatus.expired; + } + return storedStatus; + } + + public OrganizationRole toOrganizationRole(OrganizationRoleEntity entity) { + List scopeIds = collectionOrEmpty(entity.getPossibleScopes()).stream() + .map(scope -> scope.getId()) + .filter(Objects::nonNull) + .sorted() + .toList(); + return new OrganizationRole(entity.getRoleId(), entity.getName(), scopeIds); + } + + private RoleScope toRoleScope(MemberRoleEntity entity) { + RoleScope scope = new RoleScope(entity.getScopeId()); + if (entity.getResourceId() != null) { + scope.setResourceId(entity.getResourceId()); + } + return scope; + } + + private OrganizationStatus toOrganizationStatus(String status) { + if (status == null) { + return OrganizationStatus.active; + } + return OrganizationStatus.valueOf(status.toLowerCase(Locale.ROOT)); + } + + private InvitationStatus toInvitationStatus(String status) { + return InvitationStatus.valueOf(status.toLowerCase(Locale.ROOT)); + } + + private String formatDateTime(LocalDateTime dateTime) { + return dateTime == null ? null : dateTime.atOffset(ZoneOffset.UTC).toString(); + } + + private Collection collectionOrEmpty(Collection collection) { + return collection == null ? List.of() : collection; + } +} diff --git a/src/main/java/dev/vality/orgmanager/entity/OrganizationEntity.java b/src/main/java/dev/vality/orgmanager/entity/OrganizationEntity.java index c5ccf292..e32f09eb 100644 --- a/src/main/java/dev/vality/orgmanager/entity/OrganizationEntity.java +++ b/src/main/java/dev/vality/orgmanager/entity/OrganizationEntity.java @@ -42,4 +42,6 @@ public class OrganizationEntity implements Serializable { private String owner; private String party; private String metadata; + @Builder.Default + private String status = "active"; } diff --git a/src/main/java/dev/vality/orgmanager/repository/OrganizationRepository.java b/src/main/java/dev/vality/orgmanager/repository/OrganizationRepository.java index a5581c2c..808daa29 100644 --- a/src/main/java/dev/vality/orgmanager/repository/OrganizationRepository.java +++ b/src/main/java/dev/vality/orgmanager/repository/OrganizationRepository.java @@ -3,6 +3,7 @@ import dev.vality.orgmanager.entity.OrganizationEntity; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.NativeQuery; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; import java.util.List; @@ -36,4 +37,17 @@ public interface OrganizationRepository extends JpaRepository findAllByOwner(String owner); + boolean existsByParty(String party); + + List findAllByOrderByIdDesc(Pageable pageable); + + List findByIdLessThanOrderByIdDesc(String id, Pageable pageable); + + List findByStatusOrderByIdDesc(String status, Pageable pageable); + + List findByStatusAndIdLessThanOrderByIdDesc( + String status, + String id, + Pageable pageable); + } diff --git a/src/main/java/dev/vality/orgmanager/service/AdminManagementService.java b/src/main/java/dev/vality/orgmanager/service/AdminManagementService.java new file mode 100644 index 00000000..f014a7a7 --- /dev/null +++ b/src/main/java/dev/vality/orgmanager/service/AdminManagementService.java @@ -0,0 +1,393 @@ +package dev.vality.orgmanager.service; + +import dev.vality.orgmanagement.AddMemberRequest; +import dev.vality.orgmanagement.AdminManagementSrv; +import dev.vality.orgmanagement.AssignMemberRoleRequest; +import dev.vality.orgmanagement.CreateInvitationRequest; +import dev.vality.orgmanagement.CreateOrganizationRequest; +import dev.vality.orgmanagement.InvalidOrganizationState; +import dev.vality.orgmanagement.Invitation; +import dev.vality.orgmanagement.InvitationNotFound; +import dev.vality.orgmanagement.InvitationStatus; +import dev.vality.orgmanagement.ListInvitationsRequest; +import dev.vality.orgmanagement.ListOrganizationsRequest; +import dev.vality.orgmanagement.ListOrganizationsResult; +import dev.vality.orgmanagement.Member; +import dev.vality.orgmanagement.MemberNotFound; +import dev.vality.orgmanagement.MemberRole; +import dev.vality.orgmanagement.MemberRoleNotFound; +import dev.vality.orgmanagement.Organization; +import dev.vality.orgmanagement.OrganizationNotFound; +import dev.vality.orgmanagement.OrganizationRole; +import dev.vality.orgmanagement.OrganizationStatus; +import dev.vality.orgmanagement.PartyAlreadyBound; +import dev.vality.orgmanagement.RevokeInvitationRequest; +import dev.vality.orgmanager.config.properties.InviteTokenProperties; +import dev.vality.orgmanager.converter.AdminManagementConverter; +import dev.vality.orgmanager.entity.InvitationEntity; +import dev.vality.orgmanager.entity.MemberEntity; +import dev.vality.orgmanager.entity.MemberRoleEntity; +import dev.vality.orgmanager.entity.OrganizationEntity; +import dev.vality.orgmanager.entity.OrganizationRoleEntity; +import dev.vality.orgmanager.repository.InvitationRepository; +import dev.vality.orgmanager.repository.MemberRepository; +import dev.vality.orgmanager.repository.MemberRoleRepository; +import dev.vality.orgmanager.repository.OrganizationRepository; +import dev.vality.orgmanager.repository.OrganizationRoleRepository; +import dev.vality.swag.organizations.model.InvitationStatusName; +import dev.vality.swag.organizations.model.InviteeContact; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminManagementService implements AdminManagementSrv.Iface { + + static final int DEFAULT_ORGANIZATION_LIMIT = 20; + static final int MAX_ORGANIZATION_LIMIT = 1000; + + private static final String ACTIVE_STATUS = OrganizationStatus.active.name(); + private static final String DEACTIVATED_STATUS = OrganizationStatus.deactivated.name(); + + private final OrganizationRepository organizationRepository; + private final MemberRepository memberRepository; + private final MemberRoleRepository memberRoleRepository; + private final OrganizationRoleRepository organizationRoleRepository; + private final InvitationRepository invitationRepository; + private final AdminManagementConverter converter; + private final InviteTokenProperties inviteTokenProperties; + private final MailMessageSender mailMessageSender; + + @Override + @Transactional + public Organization createOrganization(CreateOrganizationRequest request) throws PartyAlreadyBound { + if (organizationRepository.existsByParty(request.getPartyId())) { + throw new PartyAlreadyBound(); + } + OrganizationEntity entity = OrganizationEntity.builder() + .id(UUID.randomUUID().toString()) + .party(request.getPartyId()) + .owner(request.getOwnerId()) + .name(request.getName()) + .metadata(request.getMetadata()) + .createdAt(LocalDateTime.now()) + .status(ACTIVE_STATUS) + .members(new HashSet<>()) + .roles(new HashSet<>()) + .build(); + try { + return converter.toOrganization(organizationRepository.saveAndFlush(entity)); + } catch (DataIntegrityViolationException exception) { + throw new PartyAlreadyBound(); + } + } + + @Override + public Organization getOrganization(String organizationId) throws OrganizationNotFound { + return converter.toOrganization(findOrganization(organizationId)); + } + + @Override + public ListOrganizationsResult listOrganizations(ListOrganizationsRequest request) { + ListOrganizationsRequest safeRequest = request == null ? new ListOrganizationsRequest() : request; + int limit = organizationLimit(safeRequest); + Pageable pageable = PageRequest.of(0, limit + 1); + List entities = findOrganizations(safeRequest, pageable); + + String continuationToken = null; + if (entities.size() > limit) { + entities = new ArrayList<>(entities.subList(0, limit)); + continuationToken = entities.get(entities.size() - 1).getId(); + } + ListOrganizationsResult result = new ListOrganizationsResult( + entities.stream().map(converter::toOrganization).toList()); + if (continuationToken != null) { + result.setContinuationToken(continuationToken); + } + return result; + } + + @Override + @Transactional + public Organization renameOrganization(String organizationId, String name) throws OrganizationNotFound { + OrganizationEntity organization = findOrganization(organizationId); + organization.setName(name); + return converter.toOrganization(organizationRepository.save(organization)); + } + + @Override + @Transactional + public Organization deactivateOrganization(String organizationId) + throws OrganizationNotFound, InvalidOrganizationState { + return changeOrganizationStatus(organizationId, ACTIVE_STATUS, DEACTIVATED_STATUS); + } + + @Override + @Transactional + public Organization activateOrganization(String organizationId) + throws OrganizationNotFound, InvalidOrganizationState { + return changeOrganizationStatus(organizationId, DEACTIVATED_STATUS, ACTIVE_STATUS); + } + + @Override + public Member getMember(String organizationId, String userId) + throws OrganizationNotFound, MemberNotFound { + OrganizationEntity organization = findOrganization(organizationId); + return converter.toMember(findMember(organization, userId), organizationId); + } + + @Override + public List listMembers(String organizationId) throws OrganizationNotFound { + OrganizationEntity organization = findOrganization(organizationId); + return collectionOrEmpty(organization.getMembers()).stream() + .sorted(Comparator.comparing(MemberEntity::getId)) + .map(member -> converter.toMember(member, organizationId)) + .toList(); + } + + @Override + @Transactional + public Member addMember(String organizationId, AddMemberRequest request) throws OrganizationNotFound { + OrganizationEntity organization = findOrganization(organizationId); + MemberEntity member = memberRepository.findById(request.getUserId()) + .orElseGet(() -> MemberEntity.builder() + .id(request.getUserId()) + .roles(new HashSet<>()) + .organizations(new HashSet<>()) + .build()); + if (request.isSetEmail()) { + member.setEmail(request.getEmail()); + } + member = memberRepository.save(member); + Set members = new HashSet<>(collectionOrEmpty(organization.getMembers())); + members.add(member); + organization.setMembers(members); + organizationRepository.save(organization); + return converter.toMember(member, organizationId); + } + + @Override + @Transactional + public void removeMember(String organizationId, String userId) + throws OrganizationNotFound, MemberNotFound { + OrganizationEntity organization = findOrganization(organizationId); + MemberEntity member = findMember(organization, userId); + + Set retainedRoles = new HashSet<>(); + List removedRoles = new ArrayList<>(); + for (MemberRoleEntity role : collectionOrEmpty(member.getRoles())) { + if (organizationId.equals(role.getOrganizationId()) && role.isActive()) { + role.setActive(false); + removedRoles.add(role); + } else { + retainedRoles.add(role); + } + } + member.setRoles(retainedRoles); + memberRoleRepository.saveAll(removedRoles); + memberRepository.save(member); + + Set members = new HashSet<>(collectionOrEmpty(organization.getMembers())); + members.remove(member); + organization.setMembers(members); + organizationRepository.save(organization); + } + + @Override + @Transactional + public MemberRole assignMemberRole( + String organizationId, + String userId, + AssignMemberRoleRequest request) throws OrganizationNotFound, MemberNotFound { + OrganizationEntity organization = findOrganization(organizationId); + MemberEntity member = findMember(organization, userId); + MemberRoleEntity role = toMemberRoleEntity(organizationId, request.getRoleId(), request.getScope()); + role = memberRoleRepository.save(role); + Set roles = new HashSet<>(collectionOrEmpty(member.getRoles())); + roles.add(role); + member.setRoles(roles); + memberRepository.save(member); + return converter.toMemberRole(role); + } + + @Override + @Transactional + public void removeMemberRole(String organizationId, String userId, String memberRoleId) + throws OrganizationNotFound, MemberNotFound, MemberRoleNotFound { + OrganizationEntity organization = findOrganization(organizationId); + MemberEntity member = findMember(organization, userId); + MemberRoleEntity role = collectionOrEmpty(member.getRoles()).stream() + .filter(candidate -> candidate.getId().equals(memberRoleId)) + .filter(candidate -> organizationId.equals(candidate.getOrganizationId())) + .filter(MemberRoleEntity::isActive) + .findFirst() + .orElseThrow(MemberRoleNotFound::new); + role.setActive(false); + Set roles = new HashSet<>(collectionOrEmpty(member.getRoles())); + roles.remove(role); + member.setRoles(roles); + memberRoleRepository.save(role); + memberRepository.save(member); + } + + @Override + public OrganizationRole getOrganizationRole(String organizationId, String roleId) + throws OrganizationNotFound { + findOrganization(organizationId); + OrganizationRoleEntity role = organizationRoleRepository.findByOrganizationIdAndRoleId(organizationId, roleId) + .orElseThrow(OrganizationNotFound::new); + return converter.toOrganizationRole(role); + } + + @Override + public List listOrganizationRoles(String organizationId) throws OrganizationNotFound { + OrganizationEntity organization = findOrganization(organizationId); + return collectionOrEmpty(organization.getRoles()).stream() + .sorted(Comparator.comparing(OrganizationRoleEntity::getRoleId)) + .map(converter::toOrganizationRole) + .toList(); + } + + @Override + @Transactional + public Invitation createInvitation(String organizationId, CreateInvitationRequest request) + throws OrganizationNotFound { + findOrganization(organizationId); + Set roles = request.getRoles().stream() + .map(role -> toMemberRoleEntity(organizationId, role.getRoleId(), role.getScope())) + .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)); + LocalDateTime now = LocalDateTime.now(); + InvitationEntity invitation = InvitationEntity.builder() + .id(UUID.randomUUID().toString()) + .organizationId(organizationId) + .createdAt(now) + .expiresAt(now.plusDays(inviteTokenProperties.getLifeTimeInDays())) + .acceptToken(UUID.randomUUID().toString()) + .inviteeContactType(InviteeContact.TypeEnum.E_MAIL.getValue()) + .inviteeContactEmail(request.getEmail()) + .inviteeRoles(roles) + .metadata(request.getMetadata()) + .status(InvitationStatusName.PENDING.getValue()) + .build(); + invitation = invitationRepository.save(invitation); + mailMessageSender.send(invitation.getAcceptToken(), invitation.getInviteeContactEmail()); + return converter.toInvitation(invitation); + } + + @Override + public Invitation getInvitation(String organizationId, String invitationId) + throws OrganizationNotFound, InvitationNotFound { + findOrganization(organizationId); + InvitationEntity invitation = invitationRepository.findByIdAndOrganizationId(invitationId, organizationId) + .orElseThrow(InvitationNotFound::new); + return converter.toInvitation(invitation); + } + + @Override + public List listInvitations(String organizationId, ListInvitationsRequest request) + throws OrganizationNotFound { + findOrganization(organizationId); + InvitationStatus status = request == null ? null : request.getStatus(); + return invitationRepository.findByOrganizationId(organizationId).stream() + .filter(invitation -> status == null || converter.effectiveInvitationStatus(invitation) == status) + .sorted(Comparator.comparing(InvitationEntity::getCreatedAt).reversed()) + .map(converter::toInvitation) + .toList(); + } + + @Override + @Transactional + public void revokeInvitation(String organizationId, String invitationId, RevokeInvitationRequest request) + throws OrganizationNotFound, InvitationNotFound, InvalidOrganizationState { + findOrganization(organizationId); + InvitationEntity invitation = invitationRepository.findByIdAndOrganizationId(invitationId, organizationId) + .orElseThrow(InvitationNotFound::new); + InvitationStatus status = converter.effectiveInvitationStatus(invitation); + if (status != InvitationStatus.pending) { + throw new InvalidOrganizationState("Only a pending invitation can be revoked"); + } + invitation.setStatus(InvitationStatusName.REVOKED.getValue()); + invitation.setRevocationReason(request.getReason()); + invitation.setRevokedAt(LocalDateTime.now()); + invitationRepository.save(invitation); + } + + private List findOrganizations(ListOrganizationsRequest request, Pageable pageable) { + String token = request.getContinuationToken(); + OrganizationStatus status = request.getStatus(); + if (status != null && token != null) { + return organizationRepository.findByStatusAndIdLessThanOrderByIdDesc(status.name(), token, pageable); + } + if (status != null) { + return organizationRepository.findByStatusOrderByIdDesc(status.name(), pageable); + } + if (token != null) { + return organizationRepository.findByIdLessThanOrderByIdDesc(token, pageable); + } + return organizationRepository.findAllByOrderByIdDesc(pageable); + } + + private int organizationLimit(ListOrganizationsRequest request) { + if (!request.isSetLimit() || request.getLimit() <= 0) { + return DEFAULT_ORGANIZATION_LIMIT; + } + return Math.min(request.getLimit(), MAX_ORGANIZATION_LIMIT); + } + + private Organization changeOrganizationStatus(String organizationId, String expected, String target) + throws OrganizationNotFound, InvalidOrganizationState { + OrganizationEntity organization = findOrganization(organizationId); + String current = organization.getStatus() == null ? ACTIVE_STATUS : organization.getStatus(); + if (!expected.equals(current)) { + throw new InvalidOrganizationState( + "Expected organization status " + expected + ", but was " + current); + } + organization.setStatus(target); + return converter.toOrganization(organizationRepository.save(organization)); + } + + private OrganizationEntity findOrganization(String organizationId) throws OrganizationNotFound { + return organizationRepository.findById(organizationId) + .orElseThrow(OrganizationNotFound::new); + } + + private MemberEntity findMember(OrganizationEntity organization, String userId) throws MemberNotFound { + return collectionOrEmpty(organization.getMembers()).stream() + .filter(member -> member.getId().equals(userId)) + .findFirst() + .orElseThrow(MemberNotFound::new); + } + + private MemberRoleEntity toMemberRoleEntity( + String organizationId, + String roleId, + dev.vality.orgmanagement.RoleScope scope) { + return MemberRoleEntity.builder() + .id(UUID.randomUUID().toString()) + .organizationId(organizationId) + .roleId(roleId) + .scopeId(scope == null ? null : scope.getScopeId()) + .resourceId(scope == null ? null : scope.getResourceId()) + .active(true) + .build(); + } + + private Collection collectionOrEmpty(Collection collection) { + return collection == null ? List.of() : collection; + } +} diff --git a/src/main/java/dev/vality/orgmanager/service/UserServiceImpl.java b/src/main/java/dev/vality/orgmanager/service/UserServiceImpl.java index 8d17c590..bed9546e 100644 --- a/src/main/java/dev/vality/orgmanager/service/UserServiceImpl.java +++ b/src/main/java/dev/vality/orgmanager/service/UserServiceImpl.java @@ -34,7 +34,13 @@ public UserInfo findById(String id) { return new UserInfo( user.orElse(null), Stream.concat(memberOrganizations.stream(), ownedOrganizations.stream()) + .filter(this::isActive) .collect(Collectors.toSet()) ); } + + private boolean isActive(OrganizationEntity organization) { + return organization.getStatus() == null + || !organization.getStatus().equalsIgnoreCase("deactivated"); + } } diff --git a/src/main/java/dev/vality/orgmanager/servlet/AdminManagementServlet.java b/src/main/java/dev/vality/orgmanager/servlet/AdminManagementServlet.java new file mode 100644 index 00000000..3894172d --- /dev/null +++ b/src/main/java/dev/vality/orgmanager/servlet/AdminManagementServlet.java @@ -0,0 +1,34 @@ +package dev.vality.orgmanager.servlet; + +import dev.vality.orgmanagement.AdminManagementSrv; +import dev.vality.woody.thrift.impl.http.THServiceBuilder; +import jakarta.servlet.GenericServlet; +import jakarta.servlet.Servlet; +import jakarta.servlet.ServletConfig; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.annotation.WebServlet; +import lombok.RequiredArgsConstructor; + +import java.io.IOException; + +@WebServlet("/admin-management") +@RequiredArgsConstructor +public class AdminManagementServlet extends GenericServlet { + + private final AdminManagementSrv.Iface adminManagement; + private Servlet thriftServlet; + + @Override + public void init(ServletConfig config) throws ServletException { + super.init(config); + thriftServlet = new THServiceBuilder() + .build(AdminManagementSrv.Iface.class, adminManagement); + } + + @Override + public void service(ServletRequest request, ServletResponse response) throws ServletException, IOException { + thriftServlet.service(request, response); + } +} diff --git a/src/main/resources/db/migration/V10__add_organization_status.sql b/src/main/resources/db/migration/V10__add_organization_status.sql new file mode 100644 index 00000000..795b1800 --- /dev/null +++ b/src/main/resources/db/migration/V10__add_organization_status.sql @@ -0,0 +1,2 @@ +ALTER TABLE org_manager.organization + ADD COLUMN IF NOT EXISTS status CHARACTER VARYING NOT NULL DEFAULT 'active'; diff --git a/src/test/java/dev/vality/orgmanager/service/AdminManagementServiceTest.java b/src/test/java/dev/vality/orgmanager/service/AdminManagementServiceTest.java new file mode 100644 index 00000000..9e45345a --- /dev/null +++ b/src/test/java/dev/vality/orgmanager/service/AdminManagementServiceTest.java @@ -0,0 +1,231 @@ +package dev.vality.orgmanager.service; + +import dev.vality.orgmanagement.AddMemberRequest; +import dev.vality.orgmanagement.AssignMemberRoleRequest; +import dev.vality.orgmanagement.CreateInvitationRequest; +import dev.vality.orgmanagement.CreateOrganizationRequest; +import dev.vality.orgmanagement.InvalidOrganizationState; +import dev.vality.orgmanagement.InvitationStatus; +import dev.vality.orgmanagement.ListInvitationsRequest; +import dev.vality.orgmanagement.ListOrganizationsRequest; +import dev.vality.orgmanagement.MemberRoleNotFound; +import dev.vality.orgmanagement.OrganizationStatus; +import dev.vality.orgmanagement.PartyAlreadyBound; +import dev.vality.orgmanagement.RevokeInvitationRequest; +import dev.vality.orgmanager.config.properties.InviteTokenProperties; +import dev.vality.orgmanager.converter.AdminManagementConverter; +import dev.vality.orgmanager.entity.InvitationEntity; +import dev.vality.orgmanager.entity.MemberEntity; +import dev.vality.orgmanager.entity.MemberRoleEntity; +import dev.vality.orgmanager.entity.OrganizationEntity; +import dev.vality.orgmanager.repository.InvitationRepository; +import dev.vality.orgmanager.repository.MemberRepository; +import dev.vality.orgmanager.repository.MemberRoleRepository; +import dev.vality.orgmanager.repository.OrganizationRepository; +import dev.vality.orgmanager.repository.OrganizationRoleRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Pageable; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminManagementServiceTest { + + @Mock + private OrganizationRepository organizationRepository; + @Mock + private MemberRepository memberRepository; + @Mock + private MemberRoleRepository memberRoleRepository; + @Mock + private OrganizationRoleRepository organizationRoleRepository; + @Mock + private InvitationRepository invitationRepository; + @Mock + private InviteTokenProperties inviteTokenProperties; + @Mock + private MailMessageSender mailMessageSender; + + private AdminManagementService service; + + @BeforeEach + void setUp() { + service = new AdminManagementService( + organizationRepository, + memberRepository, + memberRoleRepository, + organizationRoleRepository, + invitationRepository, + new AdminManagementConverter(), + inviteTokenProperties, + mailMessageSender); + } + + @Test + void shouldCreateOrganizationWithRequestedPartyAndOwner() throws Exception { + CreateOrganizationRequest request = new CreateOrganizationRequest("party", "owner", "name") + .setMetadata("{\"source\":\"admin\"}"); + when(organizationRepository.saveAndFlush(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + var result = service.createOrganization(request); + + assertEquals("party", result.getPartyId()); + assertEquals("owner", result.getOwnerId()); + assertEquals(OrganizationStatus.active, result.getStatus()); + assertEquals(request.getMetadata(), result.getMetadata()); + } + + @Test + void shouldRejectPartyAlreadyBoundToOrganization() { + CreateOrganizationRequest request = new CreateOrganizationRequest("party", "owner", "name"); + when(organizationRepository.existsByParty("party")).thenReturn(true); + + assertThrows(PartyAlreadyBound.class, () -> service.createOrganization(request)); + verifyNoInteractions(memberRepository); + } + + @Test + void shouldReturnContinuationTokenWhenMoreOrganizationsExist() { + ListOrganizationsRequest request = new ListOrganizationsRequest().setLimit(2); + List entities = List.of( + organization("c", OrganizationStatus.active), + organization("b", OrganizationStatus.active), + organization("a", OrganizationStatus.active)); + when(organizationRepository.findAllByOrderByIdDesc(any(Pageable.class))).thenReturn(entities); + + var result = service.listOrganizations(request); + + assertEquals(List.of("c", "b"), result.getOrganizations().stream().map(it -> it.getId()).toList()); + assertEquals("b", result.getContinuationToken()); + } + + @Test + void shouldValidateOrganizationStatusTransitions() throws Exception { + OrganizationEntity organization = organization("org", OrganizationStatus.active); + when(organizationRepository.findById("org")).thenReturn(Optional.of(organization)); + when(organizationRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + var deactivated = service.deactivateOrganization("org"); + + assertEquals(OrganizationStatus.deactivated, deactivated.getStatus()); + assertThrows(InvalidOrganizationState.class, () -> service.deactivateOrganization("org")); + assertEquals(OrganizationStatus.active, service.activateOrganization("org").getStatus()); + } + + @Test + void shouldAddMemberAndManageRoleWithoutLastRoleRestriction() throws Exception { + OrganizationEntity organization = organization("org", OrganizationStatus.active); + when(organizationRepository.findById("org")).thenReturn(Optional.of(organization)); + when(organizationRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(memberRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(memberRoleRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + var member = service.addMember("org", new AddMemberRequest("user").setEmail("user@example.com")); + var role = service.assignMemberRole("org", "user", new AssignMemberRoleRequest("manager")); + service.removeMemberRole("org", "user", role.getId()); + + assertEquals("user@example.com", member.getEmail()); + assertTrue(service.getMember("org", "user").getRoles().isEmpty()); + } + + @Test + void shouldRejectRemovingRoleFromAnotherOrganization() throws Exception { + MemberRoleEntity role = MemberRoleEntity.builder() + .id("role") + .organizationId("another-org") + .roleId("manager") + .active(true) + .build(); + MemberEntity member = MemberEntity.builder() + .id("user") + .roles(new HashSet<>(Set.of(role))) + .build(); + OrganizationEntity organization = organization("org", OrganizationStatus.active); + organization.setMembers(new HashSet<>(Set.of(member))); + when(organizationRepository.findById("org")).thenReturn(Optional.of(organization)); + + assertThrows(MemberRoleNotFound.class, + () -> service.removeMemberRole("org", "user", "role")); + } + + @Test + void shouldUseEffectiveInvitationStatusForListingAndRevocation() throws Exception { + OrganizationEntity organization = organization("org", OrganizationStatus.active); + InvitationEntity expired = invitation("expired", LocalDateTime.now().minusMinutes(1)); + InvitationEntity pending = invitation("pending", LocalDateTime.now().plusDays(1)); + when(organizationRepository.findById("org")).thenReturn(Optional.of(organization)); + when(invitationRepository.findByOrganizationId("org")).thenReturn(List.of(expired, pending)); + when(invitationRepository.findByIdAndOrganizationId("expired", "org")).thenReturn(Optional.of(expired)); + when(invitationRepository.findByIdAndOrganizationId("pending", "org")).thenReturn(Optional.of(pending)); + + var result = service.listInvitations( + "org", + new ListInvitationsRequest().setStatus(InvitationStatus.expired)); + + assertEquals(List.of("expired"), result.stream().map(it -> it.getId()).toList()); + assertThrows(InvalidOrganizationState.class, + () -> service.revokeInvitation("org", "expired", new RevokeInvitationRequest("obsolete"))); + service.revokeInvitation("org", "pending", new RevokeInvitationRequest("obsolete")); + assertEquals("Revoked", pending.getStatus()); + assertEquals("obsolete", pending.getRevocationReason()); + verify(invitationRepository).save(pending); + } + + @Test + void shouldCreateInvitationAndSendMail() throws Exception { + OrganizationEntity organization = organization("org", OrganizationStatus.active); + when(organizationRepository.findById("org")).thenReturn(Optional.of(organization)); + when(inviteTokenProperties.getLifeTimeInDays()).thenReturn(7L); + when(invitationRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + CreateInvitationRequest request = new CreateInvitationRequest("user@example.com", new ArrayList<>()); + + var result = service.createInvitation("org", request); + + assertEquals(InvitationStatus.pending, result.getStatus()); + assertEquals("user@example.com", result.getEmail()); + verify(mailMessageSender).send(any(String.class), eq("user@example.com")); + } + + private OrganizationEntity organization(String id, OrganizationStatus status) { + return OrganizationEntity.builder() + .id(id) + .party("party-" + id) + .owner("owner-" + id) + .name("name-" + id) + .createdAt(LocalDateTime.now()) + .status(status.name()) + .members(new HashSet<>()) + .roles(new HashSet<>()) + .build(); + } + + private InvitationEntity invitation(String id, LocalDateTime expiresAt) { + return InvitationEntity.builder() + .id(id) + .organizationId("org") + .createdAt(LocalDateTime.now()) + .expiresAt(expiresAt) + .inviteeContactEmail("user@example.com") + .inviteeRoles(new HashSet<>()) + .status("Pending") + .build(); + } +} diff --git a/src/test/java/dev/vality/orgmanager/service/UserServiceImplTest.java b/src/test/java/dev/vality/orgmanager/service/UserServiceImplTest.java index 3fe8f5cb..be52e725 100644 --- a/src/test/java/dev/vality/orgmanager/service/UserServiceImplTest.java +++ b/src/test/java/dev/vality/orgmanager/service/UserServiceImplTest.java @@ -124,4 +124,19 @@ void findMemberUserWithSameMemberAndOwnedOrganizations() { assertEquals(1, userInfo.getOrganizations().size()); assertEquals(organization.getId(), userInfo.getOrganizations().iterator().next().getId()); } + + @Test + void doesNotExposeDeactivatedOrganizationInUserContext() { + String memberId = TestObjectFactory.randomString(); + MemberEntity member = TestObjectFactory.testMemberEntity(memberId); + OrganizationEntity organization = TestObjectFactory.buildOrganization(member); + organization.setStatus("deactivated"); + memberRepository.save(member); + organizationRepository.save(organization); + + UserInfo userInfo = userService.findById(memberId); + + assertEquals(memberId, userInfo.getMember().getId()); + assertTrue(userInfo.getOrganizations().isEmpty()); + } }