RepositoryMembershipService.java
package com.taxonomy.workspace.service;
import com.taxonomy.workspace.model.RepositoryLifecycleState;
import com.taxonomy.workspace.model.RepositoryMembership;
import com.taxonomy.workspace.model.RepositoryOwnerType;
import com.taxonomy.workspace.model.RepositoryRole;
import com.taxonomy.workspace.model.RepositoryVisibility;
import com.taxonomy.workspace.model.SystemRepository;
import com.taxonomy.workspace.repository.RepositoryMembershipRepository;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
/** Evaluates and persists repository-scoped memberships independently of global app roles. */
@Service
public class RepositoryMembershipService {
private final RepositoryMembershipRepository membershipRepository;
public RepositoryMembershipService(RepositoryMembershipRepository membershipRepository) {
this.membershipRepository = membershipRepository;
}
/** Assign the repository creator as its explicit owner, idempotently. */
@Transactional
public RepositoryMembership assignOwner(String repositoryId, String username) {
return assignRole(repositoryId, username, RepositoryRole.OWNER, username);
}
/**
* Persist a role assignment. Authorization of the actor belongs to the calling API/service;
* this method owns validation and idempotent persistence only.
*/
@Transactional
public RepositoryMembership assignRole(
String repositoryId,
String username,
RepositoryRole role,
String actor) {
String repository = requireText(repositoryId, "repositoryId");
String member = requireText(username, "username");
String changedBy = requireText(actor, "actor");
if (role == null) {
throw new IllegalArgumentException("role must not be null");
}
RepositoryMembership membership = membershipRepository
.findByRepositoryIdAndUsername(repository, member)
.orElse(null);
return saveRole(repository, member, role, changedBy, membership);
}
/** Assign or change a membership after enforcing repository-owner authority. */
@Transactional
public RepositoryMembership assignRole(
SystemRepository repository,
String username,
RepositoryRole role,
String actor) {
String repositoryId = requireRepositoryId(repository);
String member = requireText(username, "username");
String changedBy = requireText(actor, "actor");
if (role == null) {
throw new IllegalArgumentException("role must not be null");
}
List<RepositoryMembership> lockedMemberships = membershipRepository
.findByRepositoryIdForUpdate(repositoryId);
requireOwner(repository, changedBy, lockedMemberships);
if (isUserCatalogOwner(repository, member) && role != RepositoryRole.OWNER) {
throw new IllegalArgumentException(
"The repository catalog owner must retain the OWNER role");
}
RepositoryMembership existing = findMembership(lockedMemberships, member)
.orElse(null);
if (existing != null
&& existing.getRole() == RepositoryRole.OWNER
&& role != RepositoryRole.OWNER
&& !hasImplicitUserOwner(repository)
&& ownerCount(lockedMemberships) <= 1) {
throw new IllegalStateException("A repository must retain at least one OWNER");
}
return saveRole(repositoryId, member, role, changedBy, existing);
}
/** Remove a membership after enforcing repository-owner authority and owner retention. */
@Transactional
public void removeMembership(
SystemRepository repository,
String username,
String actor) {
String repositoryId = requireRepositoryId(repository);
String member = requireText(username, "username");
String changedBy = requireText(actor, "actor");
List<RepositoryMembership> lockedMemberships = membershipRepository
.findByRepositoryIdForUpdate(repositoryId);
requireOwner(repository, changedBy, lockedMemberships);
if (isUserCatalogOwner(repository, member)) {
throw new IllegalArgumentException(
"The repository catalog owner membership cannot be removed");
}
RepositoryMembership membership = findMembership(lockedMemberships, member)
.orElseThrow(() -> new IllegalArgumentException(
"Repository membership does not exist: " + member));
if (membership.getRole() == RepositoryRole.OWNER
&& !hasImplicitUserOwner(repository)
&& ownerCount(lockedMemberships) <= 1) {
throw new IllegalStateException("A repository must retain at least one OWNER");
}
membershipRepository.delete(membership);
}
public Optional<RepositoryRole> effectiveRole(
SystemRepository repository, String username) {
if (repository == null || username == null || username.isBlank()) {
return Optional.empty();
}
String member = username.strip();
if (isUserCatalogOwner(repository, member)) {
return Optional.of(RepositoryRole.OWNER);
}
return membershipRepository.findByRepositoryIdAndUsername(
repository.getRepositoryId(), member)
.map(RepositoryMembership::getRole);
}
/**
* Fail-closed read decision. Public repositories and the historic primary repository are
* readable without an explicit membership; all other visibility modes require membership.
*/
public boolean canRead(SystemRepository repository, String username) {
if (!isActive(repository)) {
return false;
}
if (repository.isPrimaryRepo()
|| repository.getVisibility() == RepositoryVisibility.PUBLIC) {
return true;
}
return effectiveRole(repository, username)
.map(role -> role.grants(RepositoryRole.READER))
.orElse(false);
}
/**
* Working-copy creation requires CONTRIBUTOR. The historic primary repository remains a
* compatibility exception for authenticated users until installations can bootstrap explicit
* memberships for that pre-existing shared repository.
*/
public boolean canContribute(SystemRepository repository, String username) {
if (!isActive(repository) || username == null || username.isBlank()) {
return false;
}
if (repository.isPrimaryRepo()) {
return true;
}
return effectiveRole(repository, username)
.map(role -> role.grants(RepositoryRole.CONTRIBUTOR))
.orElse(false);
}
public boolean canMaintain(SystemRepository repository, String username) {
return isActive(repository)
&& effectiveRole(repository, username)
.map(role -> role.grants(RepositoryRole.MAINTAINER))
.orElse(false);
}
public boolean isOwner(SystemRepository repository, String username) {
return isActive(repository)
&& effectiveRole(repository, username)
.map(role -> role.grants(RepositoryRole.OWNER))
.orElse(false);
}
public List<RepositoryMembership> listMemberships(String repositoryId) {
return membershipRepository.findByRepositoryIdOrderByUsernameAsc(
requireText(repositoryId, "repositoryId"));
}
/** List memberships only for an explicit repository owner. */
public List<RepositoryMembership> listMemberships(
SystemRepository repository, String actor) {
requireOwner(repository, actor);
return listMemberships(repository.getRepositoryId());
}
public long countOwners(String repositoryId) {
return membershipRepository.countByRepositoryIdAndRole(
requireText(repositoryId, "repositoryId"), RepositoryRole.OWNER);
}
private RepositoryMembership saveRole(
String repositoryId,
String username,
RepositoryRole role,
String actor,
RepositoryMembership existing) {
Instant now = Instant.now();
RepositoryMembership membership = existing;
if (membership == null) {
membership = new RepositoryMembership();
membership.setRepositoryId(repositoryId);
membership.setUsername(username);
membership.setCreatedAt(now);
membership.setCreatedBy(actor);
}
membership.setRole(role);
membership.setUpdatedAt(now);
return membershipRepository.save(membership);
}
private void requireOwner(
SystemRepository repository,
String actor,
List<RepositoryMembership> lockedMemberships) {
if (!isActive(repository)) {
throw new AccessDeniedException("Repository OWNER role required");
}
String username = requireText(actor, "actor");
boolean owner = isUserCatalogOwner(repository, username)
|| findMembership(lockedMemberships, username)
.map(RepositoryMembership::getRole)
.filter(role -> role.grants(RepositoryRole.OWNER))
.isPresent();
if (!owner) {
throw new AccessDeniedException("Repository OWNER role required");
}
}
private void requireOwner(SystemRepository repository, String actor) {
if (!isOwner(repository, actor)) {
throw new AccessDeniedException("Repository OWNER role required");
}
}
private static Optional<RepositoryMembership> findMembership(
List<RepositoryMembership> memberships,
String username) {
return memberships.stream()
.filter(membership -> username.equals(membership.getUsername()))
.findFirst();
}
private static long ownerCount(List<RepositoryMembership> memberships) {
return memberships.stream()
.map(RepositoryMembership::getRole)
.filter(RepositoryRole.OWNER::equals)
.count();
}
private static String requireRepositoryId(SystemRepository repository) {
if (repository == null) {
throw new IllegalArgumentException("repository must not be null");
}
return requireText(repository.getRepositoryId(), "repositoryId");
}
private static boolean isUserCatalogOwner(
SystemRepository repository, String username) {
if (repository == null || username == null) {
return false;
}
RepositoryOwnerType ownerType = repository.getOwnerType();
return (ownerType == null || ownerType == RepositoryOwnerType.USER)
&& username.equals(repository.getOwnerId());
}
private static boolean hasImplicitUserOwner(SystemRepository repository) {
return repository != null
&& (repository.getOwnerType() == null
|| repository.getOwnerType() == RepositoryOwnerType.USER)
&& repository.getOwnerId() != null
&& !repository.getOwnerId().isBlank();
}
private static boolean isActive(SystemRepository repository) {
return repository != null
&& repository.getLifecycleState() == RepositoryLifecycleState.ACTIVE;
}
private static String requireText(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " must not be blank");
}
return value.strip();
}
}