fix(policies): gate policy editing to team leaders on SaaS, not admin
Builds on the org-wide policy model (#6625): reads/runs are open to all; create/edit/delete is gated by PolicyController.requirePolicyEditingAllowed, which was admin-only. On SaaS that's the wrong role — there is a single global admin for the whole deployment, never one per org — so org users couldn't edit policies at all. Introduce a PolicyManagementAuthority strategy: self-hosted keeps the global-admin check (AdminPolicyManagementAuthority); SaaS uses the leader of the user's team (TeamLeaderPolicyManagementAuthority -> TeamSecurityExpressions.isCurrentUserTeamLeader). The proprietary policy layer stays decoupled from the team model via the interface + profile-scoped beans. Tests: TeamSecurityExpressionsTest (leader/member/no-membership/no-team/unauthenticated), plus delegation tests for both authority beans.
This commit is contained in:
+25
@@ -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();
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package stirling.software.proprietary.policy.config;
|
||||
|
||||
/**
|
||||
* The elevated role that may manage <em>any</em> 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();
|
||||
}
|
||||
+11
-10
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+31
@@ -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());
|
||||
}
|
||||
}
|
||||
+26
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
+29
@@ -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());
|
||||
}
|
||||
}
|
||||
+104
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user