diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/AdminPolicyManagementAuthority.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/AdminPolicyManagementAuthority.java new file mode 100644 index 0000000000..0c36e38dc8 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/AdminPolicyManagementAuthority.java @@ -0,0 +1,25 @@ +package stirling.software.proprietary.policy.config; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +import stirling.software.common.service.UserServiceInterface; + +/** + * Default (non-SaaS) elevated-policy role: a global admin. SaaS overrides this with a team-leader + * check (see the {@code saas}-profiled implementation). + */ +@Component +@Profile("!saas") +@RequiredArgsConstructor +public class AdminPolicyManagementAuthority implements PolicyManagementAuthority { + + private final UserServiceInterface userService; + + @Override + public boolean canManageAllPolicies() { + return userService.isCurrentUserAdmin(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyManagementAuthority.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyManagementAuthority.java new file mode 100644 index 0000000000..ad2a0e1548 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyManagementAuthority.java @@ -0,0 +1,13 @@ +package stirling.software.proprietary.policy.config; + +/** + * The elevated role that may manage any stored policy (view, edit, delete, run), beyond a + * user's own. Pluggable per deployment so the policy layer (proprietary) needn't know the team + * model: self-hosted treats a global admin as elevated; SaaS treats the leader of the user's team + * as elevated (a SaaS deployment has only a single global admin, so admin is the wrong gate there). + */ +public interface PolicyManagementAuthority { + + /** Whether the current user holds the elevated, manage-all-policies role. */ + boolean canManageAllPolicies(); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java index 85f4e81ae0..0845a59796 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java @@ -36,10 +36,10 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.job.JobResponse; -import stirling.software.common.service.UserServiceInterface; import stirling.software.common.util.TempFile; import stirling.software.common.util.TempFileManager; import stirling.software.proprietary.policy.config.PolicyAccessGuard; +import stirling.software.proprietary.policy.config.PolicyManagementAuthority; import stirling.software.proprietary.policy.engine.PolicyRunHandle; import stirling.software.proprietary.policy.engine.PolicyRunRegistry; import stirling.software.proprietary.policy.engine.PolicyRunner; @@ -72,7 +72,7 @@ public class PolicyController { private final PolicyStore policyStore; private final PolicyValidator policyValidator; private final PolicyAccessGuard policyAccessGuard; - private final UserServiceInterface userService; + private final PolicyManagementAuthority policyManagementAuthority; private final ApplicationProperties applicationProperties; private final TempFileManager tempFileManager; @@ -194,21 +194,22 @@ public class PolicyController { } /** - * Creating, editing, pausing/resuming, and deleting policies is admin-only on multi-user - * deployments. Every mutation routes through {@link #savePolicy} (pause/resume re-save with a - * flipped {@code enabled} flag) or {@link #deletePolicy}, so gating those two covers them all; - * runs ({@code /run}) stay open. Single-user deployments (login disabled) have no admin - * concept, so they trust the local operator. The path allowlist for folder sources/outputs is - * enforced separately by {@link PolicyValidator} at validation time. + * Creating, editing, pausing/resuming, and deleting policies is restricted to whoever holds the + * manage-all role on multi-user deployments — a team leader on SaaS, a global admin self-hosted + * (see {@link PolicyManagementAuthority}). Every mutation routes through {@link #savePolicy} + * (pause/resume re-save with a flipped {@code enabled} flag) or {@link #deletePolicy}, so + * gating those two covers them all; runs ({@code /run}) stay open. Single-user deployments + * (login disabled) have no such role, so they trust the local operator. The path allowlist for + * folder sources/outputs is enforced separately by {@link PolicyValidator} at validation time. */ private void requirePolicyEditingAllowed() { if (!applicationProperties.getSecurity().isEnableLogin()) { return; } - if (!userService.isCurrentUserAdmin()) { + if (!policyManagementAuthority.canManageAllPolicies()) { throw new ResponseStatusException( HttpStatus.FORBIDDEN, - "Policies may only be created or modified by an administrator"); + "Policies may only be created or modified by a team leader"); } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/AdminPolicyManagementAuthorityTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/AdminPolicyManagementAuthorityTest.java new file mode 100644 index 0000000000..c6c831f434 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/AdminPolicyManagementAuthorityTest.java @@ -0,0 +1,31 @@ +package stirling.software.proprietary.policy.config; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import stirling.software.common.service.UserServiceInterface; + +/** Self-hosted: the manage-all-policies role is a global admin. */ +@ExtendWith(MockitoExtension.class) +class AdminPolicyManagementAuthorityTest { + + @Mock private UserServiceInterface userService; + + @Test + void adminMayManageAllPolicies() { + when(userService.isCurrentUserAdmin()).thenReturn(true); + assertTrue(new AdminPolicyManagementAuthority(userService).canManageAllPolicies()); + } + + @Test + void nonAdminMayNot() { + when(userService.isCurrentUserAdmin()).thenReturn(false); + assertFalse(new AdminPolicyManagementAuthority(userService).canManageAllPolicies()); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/security/TeamLeaderPolicyManagementAuthority.java b/app/saas/src/main/java/stirling/software/saas/security/TeamLeaderPolicyManagementAuthority.java new file mode 100644 index 0000000000..baa6a4dcea --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/security/TeamLeaderPolicyManagementAuthority.java @@ -0,0 +1,26 @@ +package stirling.software.saas.security; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +import stirling.software.proprietary.policy.config.PolicyManagementAuthority; + +/** + * SaaS elevated-policy role: only the LEADER of the current user's team may manage all policies. + * Replaces the self-hosted global-admin check, which is meaningless on SaaS (a single global admin + * exists for the whole deployment, never per-org). + */ +@Component +@Profile("saas") +@RequiredArgsConstructor +public class TeamLeaderPolicyManagementAuthority implements PolicyManagementAuthority { + + private final TeamSecurityExpressions teamSecurity; + + @Override + public boolean canManageAllPolicies() { + return teamSecurity.isCurrentUserTeamLeader(); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/security/TeamSecurityExpressions.java b/app/saas/src/main/java/stirling/software/saas/security/TeamSecurityExpressions.java index 0cc91991c3..383846f1ed 100644 --- a/app/saas/src/main/java/stirling/software/saas/security/TeamSecurityExpressions.java +++ b/app/saas/src/main/java/stirling/software/saas/security/TeamSecurityExpressions.java @@ -44,6 +44,18 @@ public class TeamSecurityExpressions { .orElse(false); } + /** Whether the current authenticated user is a {@code LEADER} of their own team. */ + public boolean isCurrentUserTeamLeader() { + User currentUser = getCurrentUser(); + if (currentUser == null || currentUser.getTeam() == null) { + return false; + } + return membershipRepository + .findByTeamIdAndUserId(currentUser.getTeam().getId(), currentUser.getId()) + .map(membership -> membership.getRole() == TeamRole.LEADER) + .orElse(false); + } + /** Whether the current authenticated user is any kind of member of the given team. */ public boolean isTeamMember(Long teamId) { User currentUser = getCurrentUser(); diff --git a/app/saas/src/test/java/stirling/software/saas/security/TeamLeaderPolicyManagementAuthorityTest.java b/app/saas/src/test/java/stirling/software/saas/security/TeamLeaderPolicyManagementAuthorityTest.java new file mode 100644 index 0000000000..25fa3cd217 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/security/TeamLeaderPolicyManagementAuthorityTest.java @@ -0,0 +1,29 @@ +package stirling.software.saas.security; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** SaaS: the manage-all-policies role is the team leader (not a global admin). */ +@ExtendWith(MockitoExtension.class) +class TeamLeaderPolicyManagementAuthorityTest { + + @Mock private TeamSecurityExpressions teamSecurity; + + @Test + void teamLeaderMayManageAllPolicies() { + when(teamSecurity.isCurrentUserTeamLeader()).thenReturn(true); + assertTrue(new TeamLeaderPolicyManagementAuthority(teamSecurity).canManageAllPolicies()); + } + + @Test + void nonLeaderMayNot() { + when(teamSecurity.isCurrentUserTeamLeader()).thenReturn(false); + assertFalse(new TeamLeaderPolicyManagementAuthority(teamSecurity).canManageAllPolicies()); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/security/TeamSecurityExpressionsTest.java b/app/saas/src/test/java/stirling/software/saas/security/TeamSecurityExpressionsTest.java new file mode 100644 index 0000000000..824389be90 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/security/TeamSecurityExpressionsTest.java @@ -0,0 +1,104 @@ +package stirling.software.saas.security; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.AfterEach; +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.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import stirling.software.common.model.enumeration.TeamRole; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.service.UserService; +import stirling.software.saas.model.TeamMembership; +import stirling.software.saas.repository.TeamMembershipRepository; + +/** + * {@link TeamSecurityExpressions#isCurrentUserTeamLeader()} — used to gate policy editing on SaaS. + */ +@ExtendWith(MockitoExtension.class) +class TeamSecurityExpressionsTest { + + @Mock private TeamMembershipRepository membershipRepository; + @Mock private UserService userService; + + private static final long TEAM_ID = 2L; + private static final long USER_ID = 1L; + + private TeamSecurityExpressions expressions() { + return new TeamSecurityExpressions(membershipRepository, userService); + } + + @AfterEach + void clearContext() { + SecurityContextHolder.clearContext(); + } + + private void authenticateAsUserWithTeam(boolean hasTeam) { + User user = new User(); + user.setId(USER_ID); + if (hasTeam) { + Team team = new Team(); + team.setId(TEAM_ID); + user.setTeam(team); + } + // API-key auth path: the principal is the User entity itself. + SecurityContextHolder.getContext() + .setAuthentication(new UsernamePasswordAuthenticationToken(user, null, List.of())); + } + + private TeamMembership membershipWithRole(TeamRole role) { + TeamMembership membership = new TeamMembership(); + membership.setRole(role); + return membership; + } + + @Test + void leaderOfOwnTeamIsLeader() { + authenticateAsUserWithTeam(true); + when(membershipRepository.findByTeamIdAndUserId(TEAM_ID, USER_ID)) + .thenReturn(Optional.of(membershipWithRole(TeamRole.LEADER))); + assertTrue(expressions().isCurrentUserTeamLeader()); + } + + @Test + void regularMemberIsNotLeader() { + authenticateAsUserWithTeam(true); + when(membershipRepository.findByTeamIdAndUserId(TEAM_ID, USER_ID)) + .thenReturn(Optional.of(membershipWithRole(TeamRole.MEMBER))); + assertFalse(expressions().isCurrentUserTeamLeader()); + } + + @Test + void noMembershipIsNotLeader() { + authenticateAsUserWithTeam(true); + when(membershipRepository.findByTeamIdAndUserId(TEAM_ID, USER_ID)) + .thenReturn(Optional.empty()); + assertFalse(expressions().isCurrentUserTeamLeader()); + } + + @Test + void userWithoutTeamIsNotLeader() { + authenticateAsUserWithTeam(false); + assertFalse(expressions().isCurrentUserTeamLeader()); + } + + @Test + void unauthenticatedIsNotLeader() { + // No authentication set on the context. + lenient() + .when(membershipRepository.findByTeamIdAndUserId(TEAM_ID, USER_ID)) + .thenReturn(Optional.of(membershipWithRole(TeamRole.LEADER))); + assertFalse(expressions().isCurrentUserTeamLeader()); + } +}