Compare commits

...
Author SHA1 Message Date
Ludy 832172e245 Merge branch 'main' into guard_internal_api_principal_20260806 2026-08-06 14:50:46 +02:00
Ludy87 41097eb027 Update AuthController.java 2026-08-06 14:49:53 +02:00
Ludy87 c49f419447 format 2026-08-06 14:03:29 +02:00
Ludy87 d2c5f2877d Introduce internal API user check and update auth guards
Add PrincipalPolicy.isInternalApiUser(...) and refactor isHumanUser(...) to delegate to it. Replace SpEL usages of @principalPolicy.isHumanUser(authentication) with !@principalPolicy.isInternalApiUser(authentication) in AuthController and ProprietaryUIDataController. Add unit tests to cover null/missing authentication and internal-api user detection. This centralises internal API account detection and simplifies authorization expressions.
2026-08-06 13:59:43 +02:00
Ludy 45e9dfa35e Merge branch 'main' into guard_internal_api_principal_20260806 2026-08-06 12:56:59 +02:00
Ludy87 afaea63396 Restrict endpoints to human users
Add PrincipalPolicy (isHumanUser) and unit tests to ensure endpoints are callable only by real human users. Apply @PreAuthorize("@principalPolicy.isHumanUser(authentication)") to AuthController, UserController, ProprietaryUIDataController (/account) and class-level SigningSessionController. Update InternalApiClientTest to assert internal client cannot POST to user API-key endpoint. Purpose: prevent anonymous/internal-api principals from accessing user-only operations (including API key access).
2026-08-06 12:47:50 +02:00
7 changed files with 151 additions and 13 deletions
@@ -221,6 +221,13 @@ class InternalApiClientTest {
assertThrows(SecurityException.class, () -> client.post("/api/v1/admin/settings", body));
}
@Test
void postRejectsUserEndpointsIncludingApiKeyEndpoint() {
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
assertThrows(SecurityException.class, () -> client.post("/api/v1/user/get-api-key", body));
}
@Test
void postRejectsAiEndpointsOutsideToolsSubnamespace() {
// /api/v1/ai/orchestrate and other non-tool AI endpoints are not internally
@@ -399,7 +399,8 @@ public class ProprietaryUIDataController {
}
@GetMapping("/account")
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PreAuthorize(
"!@principalPolicy.isInternalApiUser(authentication) && !hasAuthority('ROLE_DEMO_USER')")
@Operation(summary = "Get account page data")
public ResponseEntity<AccountData> getAccountData(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) {
@@ -0,0 +1,29 @@
package stirling.software.proprietary.security;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;
import stirling.software.common.model.enumeration.Role;
/** Authorization predicates for endpoints that must only be callable by a real user. */
@Component("principalPolicy")
public class PrincipalPolicy {
public boolean isInternalApiUser(Authentication authentication) {
return authentication != null
&& (Role.INTERNAL_API_USER.getRoleId().equals(authentication.getName())
|| authentication.getAuthorities().stream()
.anyMatch(
authority ->
Role.INTERNAL_API_USER
.getRoleId()
.equals(authority.getAuthority())));
}
public boolean isHumanUser(Authentication authentication) {
return authentication != null
&& authentication.isAuthenticated()
&& !"anonymousUser".equals(authentication.getName())
&& !isInternalApiUser(authentication);
}
}
@@ -288,7 +288,8 @@ public class AuthController {
* @param response HTTP response
* @return Success message
*/
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PreAuthorize(
"!@principalPolicy.isInternalApiUser(authentication) && !hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/logout")
public ResponseEntity<?> logout(HttpServletRequest request, HttpServletResponse response) {
try {
@@ -314,7 +315,8 @@ public class AuthController {
* @param response HTTP response to set new JWT cookie
* @return New token information
*/
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PreAuthorize(
"!@principalPolicy.isInternalApiUser(authentication) && !hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/refresh")
public ResponseEntity<?> refresh(HttpServletRequest request, HttpServletResponse response) {
try {
@@ -418,7 +420,7 @@ public class AuthController {
}
}
@PreAuthorize("isAuthenticated() && !hasAuthority('ROLE_DEMO_USER')")
@PreAuthorize("@principalPolicy.isHumanUser(authentication) && !hasAuthority('ROLE_DEMO_USER')")
@GetMapping("/mfa/setup")
public ResponseEntity<?> setupMfa(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) {
@@ -454,7 +456,7 @@ public class AuthController {
}
}
@PreAuthorize("isAuthenticated() && !hasAuthority('ROLE_DEMO_USER')")
@PreAuthorize("@principalPolicy.isHumanUser(authentication) && !hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/mfa/enable")
public ResponseEntity<?> enableMfa(
@RequestBody MfaCodeRequest request, Authentication authentication) {
@@ -506,7 +508,7 @@ public class AuthController {
}
}
@PreAuthorize("isAuthenticated() && !hasAuthority('ROLE_DEMO_USER')")
@PreAuthorize("@principalPolicy.isHumanUser(authentication) && !hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/mfa/disable")
public ResponseEntity<?> disableMfa(
@RequestBody MfaCodeRequest request, Authentication authentication) {
@@ -561,7 +563,7 @@ public class AuthController {
}
}
@PreAuthorize("isAuthenticated() && !hasAuthority('ROLE_DEMO_USER')")
@PreAuthorize("@principalPolicy.isHumanUser(authentication) && !hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/mfa/setup/cancel")
public ResponseEntity<?> cancelMfaSetup(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) {
@@ -162,7 +162,7 @@ public class UserController {
return userMap;
}
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PreAuthorize("@principalPolicy.isHumanUser(authentication) && !hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/change-username")
@Audited(type = AuditEventType.USER_PROFILE_UPDATE, level = AuditLevel.BASIC)
public ResponseEntity<?> changeUsername(
@@ -222,7 +222,7 @@ public class UserController {
"Username changed successfully. Please log in again."));
}
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PreAuthorize("@principalPolicy.isHumanUser(authentication) && !hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/change-password-on-login")
@Audited(type = AuditEventType.USER_PROFILE_UPDATE, level = AuditLevel.BASIC)
public ResponseEntity<?> changePasswordOnLogin(
@@ -298,7 +298,7 @@ public class UserController {
"Password changed successfully. Please log in again."));
}
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PreAuthorize("@principalPolicy.isHumanUser(authentication) && !hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/change-password")
@Audited(type = AuditEventType.USER_PROFILE_UPDATE, level = AuditLevel.BASIC)
public ResponseEntity<?> changePassword(
@@ -333,7 +333,7 @@ public class UserController {
"Password changed successfully. Please log in again."));
}
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PreAuthorize("@principalPolicy.isHumanUser(authentication) && !hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/updateUserSettings")
/**
* Updates the user settings based on the provided JSON payload.
@@ -815,7 +815,7 @@ public class UserController {
return ResponseEntity.ok(Map.of("message", "User deleted successfully"));
}
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PreAuthorize("@principalPolicy.isHumanUser(authentication) && !hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/get-api-key")
public ResponseEntity<Map<String, String>> getApiKey(Principal principal) {
if (principal == null) {
@@ -831,7 +831,7 @@ public class UserController {
return ResponseEntity.ok(Map.of("apiKey", apiKey));
}
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PreAuthorize("@principalPolicy.isHumanUser(authentication) && !hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/update-api-key")
public ResponseEntity<Map<String, String>> updateApiKey(Principal principal) {
if (principal == null) {
@@ -954,6 +954,7 @@ public class UserController {
}
}
@PreAuthorize("@principalPolicy.isHumanUser(authentication)")
@PostMapping("/complete-initial-setup")
public ResponseEntity<?> completeInitialSetup() {
try {
@@ -982,6 +983,7 @@ public class UserController {
}
// Lists enabled users for the signing picker; 'org' scope = instance-wide, else caller's team.
@PreAuthorize("@principalPolicy.isHumanUser(authentication)")
@GetMapping("/users")
public ResponseEntity<List<UserSummaryDTO>> listUsers(Principal principal) {
if (principal == null) {
@@ -7,6 +7,7 @@ import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
@@ -47,6 +48,7 @@ import stirling.software.proprietary.workflow.service.WorkflowSessionService;
@Slf4j
@RestController
@RequestMapping("/api/v1/security")
@PreAuthorize("@principalPolicy.isHumanUser(authentication)")
@Tag(
name = "Signing Sessions",
description = "Signing session lifecycle and participant management")
@@ -0,0 +1,95 @@
package stirling.software.proprietary.security;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import stirling.software.common.model.enumeration.Role;
class PrincipalPolicyTest {
private final PrincipalPolicy policy = new PrincipalPolicy();
@Test
void rejectsNullAuthentication() {
assertFalse(policy.isHumanUser(null));
}
@Test
void doesNotTreatMissingAuthenticationAsInternal() {
assertFalse(policy.isInternalApiUser(null));
}
@Test
void rejectsUnauthenticatedAuthentication() {
Authentication authentication =
new UsernamePasswordAuthenticationToken("peter", "password");
assertFalse(policy.isHumanUser(authentication));
}
@Test
void rejectsAnonymousAuthentication() {
Authentication authentication =
new AnonymousAuthenticationToken(
"test-key",
"anonymousUser",
List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS")));
assertFalse(policy.isHumanUser(authentication));
}
@Test
void acceptsAuthenticatedHumanUser() {
assertTrue(policy.isHumanUser(authenticated("peter")));
}
@Test
void acceptsAuthenticatedAdminUser() {
Authentication authentication =
authenticated("admin", new SimpleGrantedAuthority(Role.ADMIN.getRoleId()));
assertTrue(policy.isHumanUser(authentication));
}
@Test
void rejectsInternalApiAuthorityEvenWithHumanUsername() {
Authentication authentication =
authenticated(
"peter", new SimpleGrantedAuthority(Role.INTERNAL_API_USER.getRoleId()));
assertTrue(policy.isInternalApiUser(authentication));
assertFalse(policy.isHumanUser(authentication));
}
@Test
void rejectsInternalApiUsernameEvenWithoutAuthority() {
Authentication authentication = authenticated(Role.INTERNAL_API_USER.getRoleId());
assertTrue(policy.isInternalApiUser(authentication));
assertFalse(policy.isHumanUser(authentication));
}
@Test
void rejectsInternalAuthorityWhenCombinedWithOtherAuthorities() {
Authentication authentication =
authenticated(
"peter",
new SimpleGrantedAuthority(Role.USER.getRoleId()),
new SimpleGrantedAuthority(Role.INTERNAL_API_USER.getRoleId()));
assertFalse(policy.isHumanUser(authentication));
}
private static Authentication authenticated(
String username, SimpleGrantedAuthority... authorities) {
return new UsernamePasswordAuthenticationToken(username, "password", List.of(authorities));
}
}