From e88d22d2fc6c7dd0c660fbe74b24eabf60fa85a6 Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Thu, 11 Jun 2026 23:21:48 +0100 Subject: [PATCH] Policies: scope to the owning team; editing restricted to team leaders (#6632) --- .../AdminPolicyManagementAuthority.java | 41 ++++++ .../policy/config/PolicyAccessGuard.java | 33 ++++- .../config/PolicyManagementAuthority.java | 22 ++++ .../policy/controller/PolicyController.java | 54 +++++--- .../proprietary/policy/model/Policy.java | 21 +++- .../policy/store/InProcessPolicyStore.java | 3 +- .../policy/store/JpaPolicyStore.java | 3 +- .../AdminPolicyManagementAuthorityTest.java | 58 +++++++++ .../policy/config/PolicyAccessGuardTest.java | 57 ++++++--- .../TeamLeaderPolicyManagementAuthority.java | 32 +++++ .../security/TeamSecurityExpressions.java | 21 ++++ ...amLeaderPolicyManagementAuthorityTest.java | 40 ++++++ .../security/TeamSecurityExpressionsTest.java | 118 ++++++++++++++++++ 13 files changed, 455 insertions(+), 48 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/config/AdminPolicyManagementAuthority.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyManagementAuthority.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/policy/config/AdminPolicyManagementAuthorityTest.java create mode 100644 app/saas/src/main/java/stirling/software/saas/security/TeamLeaderPolicyManagementAuthority.java create mode 100644 app/saas/src/test/java/stirling/software/saas/security/TeamLeaderPolicyManagementAuthorityTest.java create mode 100644 app/saas/src/test/java/stirling/software/saas/security/TeamSecurityExpressionsTest.java 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 000000000..a49c8e5aa --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/AdminPolicyManagementAuthority.java @@ -0,0 +1,41 @@ +package stirling.software.proprietary.policy.config; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.service.UserService; + +/** + * Default (non-SaaS) policy context: a global admin may edit policies; scoping uses the current + * user's team (typically a single shared team self-hosted). 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 UserService userService; + + @Override + public boolean canEditPolicies() { + return userService.isCurrentUserAdmin(); + } + + @Override + public Long currentUserTeamId() { + String username = userService.getCurrentUsername(); + if (username == null) { + return null; + } + return userService + .findByUsername(username) + .map(User::getTeam) + .map(Team::getId) + .orElse(null); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyAccessGuard.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyAccessGuard.java index f6d0c2417..1fcdbd505 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyAccessGuard.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyAccessGuard.java @@ -1,6 +1,7 @@ package stirling.software.proprietary.policy.config; import java.util.List; +import java.util.Objects; import org.springframework.stereotype.Component; @@ -11,10 +12,12 @@ import stirling.software.common.service.UserServiceInterface; import stirling.software.proprietary.policy.model.Policy; /** - * Policies are org-wide: every user may view and run any stored policy, so reads and runs are open - * to all. Creating, editing, and deleting is gated to admins at the controller (see {@code - * PolicyController#requirePolicyEditingAllowed}). The owner is still recorded server-side (for run - * / usage attribution) but no longer restricts visibility or access. + * Policies are scoped to a team: a user may view, run, edit, and delete only the policies belonging + * to their own team (the team a policy is stamped with at creation). This binds everyone — admins + * included — so no one sees or touches another team's policies. Whether a user may edit + * (vs only view/run) is a separate check gated at the controller ({@code + * PolicyController#requirePolicyEditingAllowed} → team leader). Enforced only when login is + * enabled; single-user deployments (login disabled) pass every check. */ @Component @RequiredArgsConstructor @@ -22,15 +25,33 @@ public class PolicyAccessGuard { private final UserServiceInterface userService; private final ApplicationProperties applicationProperties; + private final PolicyManagementAuthority policyManagementAuthority; /** Owner for a new policy: the current user, or {@code null} when login is disabled. */ public String ownerForNewPolicy() { return enforced() ? userService.getCurrentUsername() : null; } - /** All stored policies are visible to every user (org-wide). */ + /** Team a new policy is stamped with — the creator's team. {@code null} when login disabled. */ + public Long teamForNewPolicy() { + return enforced() ? policyManagementAuthority.currentUserTeamId() : null; + } + + /** Whether the policy belongs to the current user's team (so they may view/run/edit it). */ + public boolean canAccess(Policy policy) { + if (!enforced()) { + return true; + } + return Objects.equals(policy.teamId(), policyManagementAuthority.currentUserTeamId()); + } + + /** The subset of {@code policies} scoped to the current user's team. */ public List visible(List policies) { - return policies; + if (!enforced()) { + return policies; + } + Long teamId = policyManagementAuthority.currentUserTeamId(); + return policies.stream().filter(policy -> Objects.equals(policy.teamId(), teamId)).toList(); } private boolean enforced() { 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 000000000..0ea3c298a --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyManagementAuthority.java @@ -0,0 +1,22 @@ +package stirling.software.proprietary.policy.config; + +/** + * The current user's policy context, pluggable per deployment so the policy layer (proprietary) + * needn't know the team model. SaaS: a user may edit policies only if they lead their team, and + * every user is scoped to their own team. Self-hosted: a global admin may edit, scoped to their + * (typically single) team. Policies are isolated per team — nobody, admins included, sees or edits + * another team's policies. + */ +public interface PolicyManagementAuthority { + + /** Whether the current user may create, edit, or delete policies (for their own team). */ + boolean canEditPolicies(); + + /** + * The team that scopes the current user's policies — the team a new policy is stamped with and + * the only team whose policies the user may see/run/edit. {@code null} when it can't be + * resolved (e.g. login disabled / no team), in which case access falls back to the unteamed + * ({@code null}-team) policies. + */ + Long currentUserTeamId(); +} 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 85f4e81ae..1150348a6 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; @@ -165,23 +165,29 @@ public class PolicyController { } /** - * Assign the owner: create stamps the current user; update preserves the existing owner — so - * the client can neither forge ownership on create nor reassign it on update. Editing is gated - * to admins by {@link #requirePolicyEditingAllowed}; policies are org-wide, so there is no - * per-owner access check here. + * Assign owner + owning team server-side. Create stamps the current user and their team; update + * preserves the existing owner and team after verifying the policy belongs to the caller's team + * — so the client can neither forge ownership/team on create nor reach across teams on update + * (a policy in another team reads as not-found). */ private Policy resolveOwnership(Policy incoming) { String id = incoming.id(); if (id != null && !id.isBlank()) { Policy existing = policyStore.get(id).orElse(null); if (existing != null) { - return withOwner(incoming, existing.owner()); + if (!policyAccessGuard.canAccess(existing)) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No policy: " + id); + } + return withOwnerAndTeam(incoming, existing.owner(), existing.teamId()); } } - return withOwner(incoming, policyAccessGuard.ownerForNewPolicy()); + return withOwnerAndTeam( + incoming, + policyAccessGuard.ownerForNewPolicy(), + policyAccessGuard.teamForNewPolicy()); } - private static Policy withOwner(Policy policy, String owner) { + private static Policy withOwnerAndTeam(Policy policy, String owner, Long teamId) { return new Policy( policy.id(), policy.name(), @@ -190,32 +196,35 @@ public class PolicyController { policy.trigger(), policy.sources(), policy.steps(), - policy.output()); + policy.output(), + teamId); } /** - * 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 requires the editor role for the + * caller's team — a team leader on SaaS (see {@link PolicyManagementAuthority}); the global + * admin gets no say on SaaS. Team scoping (which team's policies) is enforced separately by + * {@link PolicyAccessGuard}. 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 to the team. 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.canEditPolicies()) { 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"); } } @GetMapping @Operation( summary = "List policies", - description = "Lists all policies (org-wide; every user sees them all).") + description = "Lists the policies belonging to the caller's team.") public List listPolicies() { return policyAccessGuard.visible(policyStore.all()); } @@ -225,6 +234,7 @@ public class PolicyController { public ResponseEntity getPolicy(@PathVariable String policyId) { return policyStore .get(policyId) + .filter(policyAccessGuard::canAccess) .map(ResponseEntity::ok) .orElseGet(() -> ResponseEntity.notFound().build()); } @@ -233,7 +243,10 @@ public class PolicyController { @Operation(summary = "Delete a policy by id") public ResponseEntity deletePolicy(@PathVariable String policyId) { requirePolicyEditingAllowed(); - if (policyStore.get(policyId).isPresent() && policyStore.delete(policyId)) { + // Scope to the caller's team: a policy in another team reads as not-found. + boolean accessible = + policyStore.get(policyId).filter(policyAccessGuard::canAccess).isPresent(); + if (accessible && policyStore.delete(policyId)) { return ResponseEntity.noContent().build(); } return ResponseEntity.notFound().build(); @@ -253,6 +266,7 @@ public class PolicyController { Policy policy = policyStore .get(policyId) + .filter(policyAccessGuard::canAccess) .orElseThrow( () -> new ResponseStatusException( diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java index 517809c2d..6d5366eb4 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java @@ -17,7 +17,8 @@ public record Policy( TriggerConfig trigger, List sources, List steps, - OutputSpec output) { + OutputSpec output, + Long teamId) { public Policy { sources = sources == null ? List.of() : List.copyOf(sources); @@ -25,6 +26,22 @@ public record Policy( output = output == null ? OutputSpec.inline() : output; } + /** + * Without an explicit owning team. Kept for the engine and tests; the controller always stamps + * a {@code teamId} on stored policies so they stay scoped to the creating user's team. + */ + public Policy( + String id, + String name, + String owner, + boolean enabled, + TriggerConfig trigger, + List sources, + List steps, + OutputSpec output) { + this(id, name, owner, enabled, trigger, sources, steps, output, null); + } + /** A policy with no configured sources (a generator, or files supplied directly to a run). */ public Policy( String id, @@ -34,7 +51,7 @@ public record Policy( TriggerConfig trigger, List steps, OutputSpec output) { - this(id, name, owner, enabled, trigger, List.of(), steps, output); + this(id, name, owner, enabled, trigger, List.of(), steps, output, null); } /** This policy's pipeline as the engine sees it. */ diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java index 21445d838..d4a1259d3 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java @@ -31,7 +31,8 @@ public class InProcessPolicyStore implements PolicyStore { policy.trigger(), policy.sources(), policy.steps(), - policy.output()); + policy.output(), + policy.teamId()); policies.put(id, stored); return stored; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java index 6a4c0428f..731e9f5d5 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java @@ -38,7 +38,8 @@ public class JpaPolicyStore implements PolicyStore { policy.trigger(), policy.sources(), policy.steps(), - policy.output()); + policy.output(), + policy.teamId()); PolicyEntity entity = new PolicyEntity(); entity.setId(id); 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 000000000..811aae6b4 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/AdminPolicyManagementAuthorityTest.java @@ -0,0 +1,58 @@ +package stirling.software.proprietary.policy.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +import java.util.Optional; + +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.proprietary.model.Team; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.service.UserService; + +/** Self-hosted policy context: a global admin may edit; scoping uses the current user's team. */ +@ExtendWith(MockitoExtension.class) +class AdminPolicyManagementAuthorityTest { + + @Mock private UserService userService; + + private AdminPolicyManagementAuthority authority() { + return new AdminPolicyManagementAuthority(userService); + } + + @Test + void adminMayEditPolicies() { + when(userService.isCurrentUserAdmin()).thenReturn(true); + assertTrue(authority().canEditPolicies()); + } + + @Test + void nonAdminMayNot() { + when(userService.isCurrentUserAdmin()).thenReturn(false); + assertFalse(authority().canEditPolicies()); + } + + @Test + void currentUserTeamIdResolvesFromTheCurrentUsersTeam() { + Team team = new Team(); + team.setId(42L); + User user = new User(); + user.setTeam(team); + when(userService.getCurrentUsername()).thenReturn("alice"); + when(userService.findByUsername("alice")).thenReturn(Optional.of(user)); + assertEquals(42L, authority().currentUserTeamId()); + } + + @Test + void currentUserTeamIdIsNullWhenNoCurrentUser() { + when(userService.getCurrentUsername()).thenReturn(null); + assertNull(authority().currentUserTeamId()); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/PolicyAccessGuardTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/PolicyAccessGuardTest.java index 90d0135ec..9f86b87ae 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/PolicyAccessGuardTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/PolicyAccessGuardTest.java @@ -1,7 +1,9 @@ package stirling.software.proprietary.policy.config; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.when; import java.util.List; @@ -17,47 +19,66 @@ import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.model.Policy; /** - * Tests for {@link PolicyAccessGuard}. Policies are org-wide: every user sees them all (no - * owner-based filtering). The owner is still assigned server-side — the current user when login is - * enabled, {@code null} otherwise — purely for run/usage attribution. + * {@link PolicyAccessGuard}: policies are scoped to the caller's team. A user sees/accesses only + * their own team's policies (admins included — there is no cross-team escape). Login disabled + * (single-user) bypasses scoping. */ @ExtendWith(MockitoExtension.class) class PolicyAccessGuardTest { @Mock private UserServiceInterface userService; + @Mock private PolicyManagementAuthority policyManagementAuthority; private PolicyAccessGuard guard(boolean loginEnabled) { ApplicationProperties properties = new ApplicationProperties(); properties.getSecurity().setEnableLogin(loginEnabled); - return new PolicyAccessGuard(userService, properties); + return new PolicyAccessGuard(userService, properties, policyManagementAuthority); } @Test - void visibleReturnsEveryPolicyWhenLoginEnabled() { - // Org-wide: a non-admin sees every policy, not just the ones they own. - List all = List.of(ownedBy("alice"), ownedBy("bob"), ownedBy("alice")); - assertEquals(all, guard(true).visible(all)); + void visibleFiltersToTheCallersTeam() { + when(policyManagementAuthority.currentUserTeamId()).thenReturn(1L); + List all = List.of(inTeam(1L), inTeam(2L), inTeam(1L), inTeam(null)); + List visible = guard(true).visible(all); + assertEquals(2, visible.size()); + assertTrue(visible.stream().allMatch(p -> Long.valueOf(1L).equals(p.teamId()))); } @Test - void visibleReturnsEveryPolicyWhenLoginDisabled() { - List all = List.of(ownedBy("alice"), ownedBy("bob")); + void visibleReturnsEverythingWhenLoginDisabled() { + List all = List.of(inTeam(1L), inTeam(2L)); assertEquals(all, guard(false).visible(all)); } @Test - void ownerForNewPolicyIsTheCurrentUserWhenLoginEnabled() { - when(userService.getCurrentUsername()).thenReturn("alice"); - assertEquals("alice", guard(true).ownerForNewPolicy()); + void canAccessOnlyOwnTeamsPolicy() { + when(policyManagementAuthority.currentUserTeamId()).thenReturn(1L); + assertTrue(guard(true).canAccess(inTeam(1L))); + assertFalse(guard(true).canAccess(inTeam(2L))); + assertFalse(guard(true).canAccess(inTeam(null))); } @Test - void ownerForNewPolicyIsNullWhenLoginDisabled() { - // Single-user deployment: no identity to attribute to. - assertNull(guard(false).ownerForNewPolicy()); + void canAccessAnythingWhenLoginDisabled() { + assertTrue(guard(false).canAccess(inTeam(2L))); } - private static Policy ownedBy(String owner) { - return new Policy("p1", "p", owner, true, null, List.of(), List.of(), OutputSpec.inline()); + @Test + void ownerAndTeamForNewPolicyComeFromTheCurrentUserWhenLoginEnabled() { + when(userService.getCurrentUsername()).thenReturn("alice"); + when(policyManagementAuthority.currentUserTeamId()).thenReturn(7L); + assertEquals("alice", guard(true).ownerForNewPolicy()); + assertEquals(7L, guard(true).teamForNewPolicy()); + } + + @Test + void ownerAndTeamForNewPolicyAreNullWhenLoginDisabled() { + assertNull(guard(false).ownerForNewPolicy()); + assertNull(guard(false).teamForNewPolicy()); + } + + private static Policy inTeam(Long teamId) { + return new Policy( + "p1", "p", "owner", true, null, List.of(), List.of(), OutputSpec.inline(), teamId); } } 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 000000000..e2f5b65b4 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/security/TeamLeaderPolicyManagementAuthority.java @@ -0,0 +1,32 @@ +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 policy context: only the LEADER of the current user's team may edit policies, and every user + * is scoped to their own team. Replaces the self-hosted global-admin check, which is meaningless on + * SaaS (a single global admin exists for the whole deployment, never per-org) — and the admin gets + * no cross-team escape: scoping binds them like everyone else. + */ +@Component +@Profile("saas") +@RequiredArgsConstructor +public class TeamLeaderPolicyManagementAuthority implements PolicyManagementAuthority { + + private final TeamSecurityExpressions teamSecurity; + + @Override + public boolean canEditPolicies() { + return teamSecurity.isCurrentUserTeamLeader(); + } + + @Override + public Long currentUserTeamId() { + return teamSecurity.currentUserTeamId(); + } +} 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 0cc91991c..3dfdf541c 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,27 @@ 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); + } + + /** The current authenticated user's team id, or {@code null} if unauthenticated / teamless. */ + public Long currentUserTeamId() { + User currentUser = getCurrentUser(); + if (currentUser == null || currentUser.getTeam() == null) { + return null; + } + return currentUser.getTeam().getId(); + } + /** 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 000000000..70cd360d7 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/security/TeamLeaderPolicyManagementAuthorityTest.java @@ -0,0 +1,40 @@ +package stirling.software.saas.security; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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 policy context: team leader may edit; scoping uses the user's team. */ +@ExtendWith(MockitoExtension.class) +class TeamLeaderPolicyManagementAuthorityTest { + + @Mock private TeamSecurityExpressions teamSecurity; + + private TeamLeaderPolicyManagementAuthority authority() { + return new TeamLeaderPolicyManagementAuthority(teamSecurity); + } + + @Test + void teamLeaderMayEditPolicies() { + when(teamSecurity.isCurrentUserTeamLeader()).thenReturn(true); + assertTrue(authority().canEditPolicies()); + } + + @Test + void nonLeaderMayNot() { + when(teamSecurity.isCurrentUserTeamLeader()).thenReturn(false); + assertFalse(authority().canEditPolicies()); + } + + @Test + void currentUserTeamIdDelegatesToTeamSecurity() { + when(teamSecurity.currentUserTeamId()).thenReturn(9L); + assertEquals(9L, authority().currentUserTeamId()); + } +} 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 000000000..23226e446 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/security/TeamSecurityExpressionsTest.java @@ -0,0 +1,118 @@ +package stirling.software.saas.security; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +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 currentUserTeamIdReturnsTheUsersTeam() { + authenticateAsUserWithTeam(true); + assertEquals(TEAM_ID, expressions().currentUserTeamId()); + } + + @Test + void currentUserTeamIdIsNullWithoutTeam() { + authenticateAsUserWithTeam(false); + assertNull(expressions().currentUserTeamId()); + } + + @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()); + } +}