mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
Policies: scope to the owning team; editing restricted to team leaders (#6632)
This commit is contained in:
+41
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
-5
@@ -1,6 +1,7 @@
|
|||||||
package stirling.software.proprietary.policy.config;
|
package stirling.software.proprietary.policy.config;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
@@ -11,10 +12,12 @@ import stirling.software.common.service.UserServiceInterface;
|
|||||||
import stirling.software.proprietary.policy.model.Policy;
|
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
|
* Policies are scoped to a team: a user may view, run, edit, and delete only the policies belonging
|
||||||
* to all. Creating, editing, and deleting is gated to admins at the controller (see {@code
|
* to their own team (the team a policy is stamped with at creation). This binds everyone — admins
|
||||||
* PolicyController#requirePolicyEditingAllowed}). The owner is still recorded server-side (for run
|
* included — so no one sees or touches another team's policies. <em>Whether</em> a user may edit
|
||||||
* / usage attribution) but no longer restricts visibility or access.
|
* (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
|
@Component
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -22,16 +25,34 @@ public class PolicyAccessGuard {
|
|||||||
|
|
||||||
private final UserServiceInterface userService;
|
private final UserServiceInterface userService;
|
||||||
private final ApplicationProperties applicationProperties;
|
private final ApplicationProperties applicationProperties;
|
||||||
|
private final PolicyManagementAuthority policyManagementAuthority;
|
||||||
|
|
||||||
/** Owner for a new policy: the current user, or {@code null} when login is disabled. */
|
/** Owner for a new policy: the current user, or {@code null} when login is disabled. */
|
||||||
public String ownerForNewPolicy() {
|
public String ownerForNewPolicy() {
|
||||||
return enforced() ? userService.getCurrentUsername() : null;
|
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<Policy> visible(List<Policy> policies) {
|
public List<Policy> visible(List<Policy> policies) {
|
||||||
|
if (!enforced()) {
|
||||||
return policies;
|
return policies;
|
||||||
}
|
}
|
||||||
|
Long teamId = policyManagementAuthority.currentUserTeamId();
|
||||||
|
return policies.stream().filter(policy -> Objects.equals(policy.teamId(), teamId)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
private boolean enforced() {
|
private boolean enforced() {
|
||||||
return applicationProperties.getSecurity().isEnableLogin();
|
return applicationProperties.getSecurity().isEnableLogin();
|
||||||
|
|||||||
+22
@@ -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();
|
||||||
|
}
|
||||||
+34
-20
@@ -36,10 +36,10 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
|
|
||||||
import stirling.software.common.model.ApplicationProperties;
|
import stirling.software.common.model.ApplicationProperties;
|
||||||
import stirling.software.common.model.job.JobResponse;
|
import stirling.software.common.model.job.JobResponse;
|
||||||
import stirling.software.common.service.UserServiceInterface;
|
|
||||||
import stirling.software.common.util.TempFile;
|
import stirling.software.common.util.TempFile;
|
||||||
import stirling.software.common.util.TempFileManager;
|
import stirling.software.common.util.TempFileManager;
|
||||||
import stirling.software.proprietary.policy.config.PolicyAccessGuard;
|
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.PolicyRunHandle;
|
||||||
import stirling.software.proprietary.policy.engine.PolicyRunRegistry;
|
import stirling.software.proprietary.policy.engine.PolicyRunRegistry;
|
||||||
import stirling.software.proprietary.policy.engine.PolicyRunner;
|
import stirling.software.proprietary.policy.engine.PolicyRunner;
|
||||||
@@ -72,7 +72,7 @@ public class PolicyController {
|
|||||||
private final PolicyStore policyStore;
|
private final PolicyStore policyStore;
|
||||||
private final PolicyValidator policyValidator;
|
private final PolicyValidator policyValidator;
|
||||||
private final PolicyAccessGuard policyAccessGuard;
|
private final PolicyAccessGuard policyAccessGuard;
|
||||||
private final UserServiceInterface userService;
|
private final PolicyManagementAuthority policyManagementAuthority;
|
||||||
private final ApplicationProperties applicationProperties;
|
private final ApplicationProperties applicationProperties;
|
||||||
private final TempFileManager tempFileManager;
|
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
|
* Assign owner + owning team server-side. Create stamps the current user and their team; update
|
||||||
* the client can neither forge ownership on create nor reassign it on update. Editing is gated
|
* preserves the existing owner and team after verifying the policy belongs to the caller's team
|
||||||
* to admins by {@link #requirePolicyEditingAllowed}; policies are org-wide, so there is no
|
* — so the client can neither forge ownership/team on create nor reach across teams on update
|
||||||
* per-owner access check here.
|
* (a policy in another team reads as not-found).
|
||||||
*/
|
*/
|
||||||
private Policy resolveOwnership(Policy incoming) {
|
private Policy resolveOwnership(Policy incoming) {
|
||||||
String id = incoming.id();
|
String id = incoming.id();
|
||||||
if (id != null && !id.isBlank()) {
|
if (id != null && !id.isBlank()) {
|
||||||
Policy existing = policyStore.get(id).orElse(null);
|
Policy existing = policyStore.get(id).orElse(null);
|
||||||
if (existing != 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(
|
return new Policy(
|
||||||
policy.id(),
|
policy.id(),
|
||||||
policy.name(),
|
policy.name(),
|
||||||
@@ -190,32 +196,35 @@ public class PolicyController {
|
|||||||
policy.trigger(),
|
policy.trigger(),
|
||||||
policy.sources(),
|
policy.sources(),
|
||||||
policy.steps(),
|
policy.steps(),
|
||||||
policy.output());
|
policy.output(),
|
||||||
|
teamId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creating, editing, pausing/resuming, and deleting policies is admin-only on multi-user
|
* Creating, editing, pausing/resuming, and deleting policies requires the editor role for the
|
||||||
* deployments. Every mutation routes through {@link #savePolicy} (pause/resume re-save with a
|
* caller's team — a team leader on SaaS (see {@link PolicyManagementAuthority}); the global
|
||||||
* flipped {@code enabled} flag) or {@link #deletePolicy}, so gating those two covers them all;
|
* admin gets no say on SaaS. Team scoping (which team's policies) is enforced separately by
|
||||||
* runs ({@code /run}) stay open. Single-user deployments (login disabled) have no admin
|
* {@link PolicyAccessGuard}. Every mutation routes through {@link #savePolicy} (pause/resume
|
||||||
* concept, so they trust the local operator. The path allowlist for folder sources/outputs is
|
* re-save with a flipped {@code enabled} flag) or {@link #deletePolicy}, so gating those two
|
||||||
* enforced separately by {@link PolicyValidator} at validation time.
|
* 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() {
|
private void requirePolicyEditingAllowed() {
|
||||||
if (!applicationProperties.getSecurity().isEnableLogin()) {
|
if (!applicationProperties.getSecurity().isEnableLogin()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!userService.isCurrentUserAdmin()) {
|
if (!policyManagementAuthority.canEditPolicies()) {
|
||||||
throw new ResponseStatusException(
|
throw new ResponseStatusException(
|
||||||
HttpStatus.FORBIDDEN,
|
HttpStatus.FORBIDDEN,
|
||||||
"Policies may only be created or modified by an administrator");
|
"Policies may only be created or modified by a team leader");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@Operation(
|
@Operation(
|
||||||
summary = "List policies",
|
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<Policy> listPolicies() {
|
public List<Policy> listPolicies() {
|
||||||
return policyAccessGuard.visible(policyStore.all());
|
return policyAccessGuard.visible(policyStore.all());
|
||||||
}
|
}
|
||||||
@@ -225,6 +234,7 @@ public class PolicyController {
|
|||||||
public ResponseEntity<Policy> getPolicy(@PathVariable String policyId) {
|
public ResponseEntity<Policy> getPolicy(@PathVariable String policyId) {
|
||||||
return policyStore
|
return policyStore
|
||||||
.get(policyId)
|
.get(policyId)
|
||||||
|
.filter(policyAccessGuard::canAccess)
|
||||||
.map(ResponseEntity::ok)
|
.map(ResponseEntity::ok)
|
||||||
.orElseGet(() -> ResponseEntity.notFound().build());
|
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||||
}
|
}
|
||||||
@@ -233,7 +243,10 @@ public class PolicyController {
|
|||||||
@Operation(summary = "Delete a policy by id")
|
@Operation(summary = "Delete a policy by id")
|
||||||
public ResponseEntity<Void> deletePolicy(@PathVariable String policyId) {
|
public ResponseEntity<Void> deletePolicy(@PathVariable String policyId) {
|
||||||
requirePolicyEditingAllowed();
|
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.noContent().build();
|
||||||
}
|
}
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
@@ -253,6 +266,7 @@ public class PolicyController {
|
|||||||
Policy policy =
|
Policy policy =
|
||||||
policyStore
|
policyStore
|
||||||
.get(policyId)
|
.get(policyId)
|
||||||
|
.filter(policyAccessGuard::canAccess)
|
||||||
.orElseThrow(
|
.orElseThrow(
|
||||||
() ->
|
() ->
|
||||||
new ResponseStatusException(
|
new ResponseStatusException(
|
||||||
|
|||||||
+19
-2
@@ -17,7 +17,8 @@ public record Policy(
|
|||||||
TriggerConfig trigger,
|
TriggerConfig trigger,
|
||||||
List<InputSpec> sources,
|
List<InputSpec> sources,
|
||||||
List<PipelineStep> steps,
|
List<PipelineStep> steps,
|
||||||
OutputSpec output) {
|
OutputSpec output,
|
||||||
|
Long teamId) {
|
||||||
|
|
||||||
public Policy {
|
public Policy {
|
||||||
sources = sources == null ? List.of() : List.copyOf(sources);
|
sources = sources == null ? List.of() : List.copyOf(sources);
|
||||||
@@ -25,6 +26,22 @@ public record Policy(
|
|||||||
output = output == null ? OutputSpec.inline() : output;
|
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<InputSpec> sources,
|
||||||
|
List<PipelineStep> 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). */
|
/** A policy with no configured sources (a generator, or files supplied directly to a run). */
|
||||||
public Policy(
|
public Policy(
|
||||||
String id,
|
String id,
|
||||||
@@ -34,7 +51,7 @@ public record Policy(
|
|||||||
TriggerConfig trigger,
|
TriggerConfig trigger,
|
||||||
List<PipelineStep> steps,
|
List<PipelineStep> steps,
|
||||||
OutputSpec output) {
|
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. */
|
/** This policy's pipeline as the engine sees it. */
|
||||||
|
|||||||
+2
-1
@@ -31,7 +31,8 @@ public class InProcessPolicyStore implements PolicyStore {
|
|||||||
policy.trigger(),
|
policy.trigger(),
|
||||||
policy.sources(),
|
policy.sources(),
|
||||||
policy.steps(),
|
policy.steps(),
|
||||||
policy.output());
|
policy.output(),
|
||||||
|
policy.teamId());
|
||||||
policies.put(id, stored);
|
policies.put(id, stored);
|
||||||
return stored;
|
return stored;
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -38,7 +38,8 @@ public class JpaPolicyStore implements PolicyStore {
|
|||||||
policy.trigger(),
|
policy.trigger(),
|
||||||
policy.sources(),
|
policy.sources(),
|
||||||
policy.steps(),
|
policy.steps(),
|
||||||
policy.output());
|
policy.output(),
|
||||||
|
policy.teamId());
|
||||||
|
|
||||||
PolicyEntity entity = new PolicyEntity();
|
PolicyEntity entity = new PolicyEntity();
|
||||||
entity.setId(id);
|
entity.setId(id);
|
||||||
|
|||||||
+58
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
-18
@@ -1,7 +1,9 @@
|
|||||||
package stirling.software.proprietary.policy.config;
|
package stirling.software.proprietary.policy.config;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
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.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -17,47 +19,66 @@ import stirling.software.proprietary.policy.model.OutputSpec;
|
|||||||
import stirling.software.proprietary.policy.model.Policy;
|
import stirling.software.proprietary.policy.model.Policy;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests for {@link PolicyAccessGuard}. Policies are org-wide: every user sees them all (no
|
* {@link PolicyAccessGuard}: policies are scoped to the caller's team. A user sees/accesses only
|
||||||
* owner-based filtering). The owner is still assigned server-side — the current user when login is
|
* their own team's policies (admins included — there is no cross-team escape). Login disabled
|
||||||
* enabled, {@code null} otherwise — purely for run/usage attribution.
|
* (single-user) bypasses scoping.
|
||||||
*/
|
*/
|
||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
class PolicyAccessGuardTest {
|
class PolicyAccessGuardTest {
|
||||||
|
|
||||||
@Mock private UserServiceInterface userService;
|
@Mock private UserServiceInterface userService;
|
||||||
|
@Mock private PolicyManagementAuthority policyManagementAuthority;
|
||||||
|
|
||||||
private PolicyAccessGuard guard(boolean loginEnabled) {
|
private PolicyAccessGuard guard(boolean loginEnabled) {
|
||||||
ApplicationProperties properties = new ApplicationProperties();
|
ApplicationProperties properties = new ApplicationProperties();
|
||||||
properties.getSecurity().setEnableLogin(loginEnabled);
|
properties.getSecurity().setEnableLogin(loginEnabled);
|
||||||
return new PolicyAccessGuard(userService, properties);
|
return new PolicyAccessGuard(userService, properties, policyManagementAuthority);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void visibleReturnsEveryPolicyWhenLoginEnabled() {
|
void visibleFiltersToTheCallersTeam() {
|
||||||
// Org-wide: a non-admin sees every policy, not just the ones they own.
|
when(policyManagementAuthority.currentUserTeamId()).thenReturn(1L);
|
||||||
List<Policy> all = List.of(ownedBy("alice"), ownedBy("bob"), ownedBy("alice"));
|
List<Policy> all = List.of(inTeam(1L), inTeam(2L), inTeam(1L), inTeam(null));
|
||||||
assertEquals(all, guard(true).visible(all));
|
List<Policy> visible = guard(true).visible(all);
|
||||||
|
assertEquals(2, visible.size());
|
||||||
|
assertTrue(visible.stream().allMatch(p -> Long.valueOf(1L).equals(p.teamId())));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void visibleReturnsEveryPolicyWhenLoginDisabled() {
|
void visibleReturnsEverythingWhenLoginDisabled() {
|
||||||
List<Policy> all = List.of(ownedBy("alice"), ownedBy("bob"));
|
List<Policy> all = List.of(inTeam(1L), inTeam(2L));
|
||||||
assertEquals(all, guard(false).visible(all));
|
assertEquals(all, guard(false).visible(all));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void ownerForNewPolicyIsTheCurrentUserWhenLoginEnabled() {
|
void canAccessOnlyOwnTeamsPolicy() {
|
||||||
when(userService.getCurrentUsername()).thenReturn("alice");
|
when(policyManagementAuthority.currentUserTeamId()).thenReturn(1L);
|
||||||
assertEquals("alice", guard(true).ownerForNewPolicy());
|
assertTrue(guard(true).canAccess(inTeam(1L)));
|
||||||
|
assertFalse(guard(true).canAccess(inTeam(2L)));
|
||||||
|
assertFalse(guard(true).canAccess(inTeam(null)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void ownerForNewPolicyIsNullWhenLoginDisabled() {
|
void canAccessAnythingWhenLoginDisabled() {
|
||||||
// Single-user deployment: no identity to attribute to.
|
assertTrue(guard(false).canAccess(inTeam(2L)));
|
||||||
assertNull(guard(false).ownerForNewPolicy());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Policy ownedBy(String owner) {
|
@Test
|
||||||
return new Policy("p1", "p", owner, true, null, List.of(), List.of(), OutputSpec.inline());
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+32
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,27 @@ public class TeamSecurityExpressions {
|
|||||||
.orElse(false);
|
.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. */
|
/** Whether the current authenticated user is any kind of member of the given team. */
|
||||||
public boolean isTeamMember(Long teamId) {
|
public boolean isTeamMember(Long teamId) {
|
||||||
User currentUser = getCurrentUser();
|
User currentUser = getCurrentUser();
|
||||||
|
|||||||
+40
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+118
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user