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());
+ }
+}