From 5e5f2dda8302e6e7e0b58cff9a3a74cf0b6446db Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com.> Date: Thu, 31 Jul 2025 12:16:01 +0100 Subject: [PATCH] org init --- .../common/model/enumeration/Role.java | 48 +++- .../proprietary/model/Organization.java | 45 ++++ .../software/proprietary/model/Team.java | 10 +- .../dto/OrganizationWithTeamCountDTO.java | 15 ++ .../security/InitialSecuritySetup.java | 82 ++++++- .../configuration/SecurityConfiguration.java | 79 ++++--- .../controller/api/OrgAdminController.java | 216 ++++++++++++++++++ .../api/OrganizationController.java | 117 ++++++++++ .../controller/api/TeamController.java | 54 ++++- .../controller/api/TeamLeadController.java | 187 +++++++++++++++ .../database/repository/UserRepository.java | 8 + .../proprietary/security/model/User.java | 84 +++++++ .../repository/OrganizationRepository.java | 23 ++ .../security/repository/TeamRepository.java | 14 ++ .../security/service/OrganizationService.java | 43 ++++ .../OrganizationValidationService.java | 79 +++++++ .../RoleBasedAuthorizationService.java | 128 +++++++++++ .../security/service/TeamService.java | 22 +- .../security/service/UserService.java | 8 + .../security/service/TeamServiceTest.java | 40 +++- 20 files changed, 1253 insertions(+), 49 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/model/Organization.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/model/dto/OrganizationWithTeamCountDTO.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/OrgAdminController.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/OrganizationController.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/TeamLeadController.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/repository/OrganizationRepository.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/service/OrganizationService.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/service/OrganizationValidationService.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/service/RoleBasedAuthorizationService.java diff --git a/app/common/src/main/java/stirling/software/common/model/enumeration/Role.java b/app/common/src/main/java/stirling/software/common/model/enumeration/Role.java index 9e3231918..bdda75d90 100644 --- a/app/common/src/main/java/stirling/software/common/model/enumeration/Role.java +++ b/app/common/src/main/java/stirling/software/common/model/enumeration/Role.java @@ -10,10 +10,23 @@ import lombok.RequiredArgsConstructor; @RequiredArgsConstructor public enum Role { - // Unlimited access + // System-wide administrator - can manage all organizations + SYSTEM_ADMIN( + "ROLE_SYSTEM_ADMIN", + Integer.MAX_VALUE, + Integer.MAX_VALUE, + "adminUserSettings.systemAdmin"), + + // Organization administrator - can manage their organization and all its teams + ORG_ADMIN("ROLE_ORG_ADMIN", Integer.MAX_VALUE, Integer.MAX_VALUE, "adminUserSettings.orgAdmin"), + + // Team leader - can manage users in their specific team + TEAM_LEAD("ROLE_TEAM_LEAD", Integer.MAX_VALUE, Integer.MAX_VALUE, "adminUserSettings.teamLead"), + + // Legacy admin role - equivalent to SYSTEM_ADMIN for backward compatibility ADMIN("ROLE_ADMIN", Integer.MAX_VALUE, Integer.MAX_VALUE, "adminUserSettings.admin"), - // Unlimited access + // Regular user with unlimited access within their team/org USER("ROLE_USER", Integer.MAX_VALUE, Integer.MAX_VALUE, "adminUserSettings.user"), // 40 API calls Per Day, 40 web calls @@ -63,4 +76,35 @@ public enum Role { } throw new IllegalArgumentException("No Role defined for id: " + roleId); } + + /** Checks if this role can manage users across all organizations */ + public boolean isSystemAdmin() { + return this == SYSTEM_ADMIN || this == ADMIN; // ADMIN for backward compatibility + } + + /** Checks if this role can manage an organization and all its teams */ + public boolean isOrgAdmin() { + return isSystemAdmin() || this == ORG_ADMIN; + } + + /** Checks if this role can manage a specific team */ + public boolean isTeamLead() { + return isOrgAdmin() || this == TEAM_LEAD; + } + + /** Gets the hierarchy level of this role (higher number = more permissions) */ + public int getHierarchyLevel() { + return switch (this) { + case SYSTEM_ADMIN, ADMIN -> 4; + case ORG_ADMIN -> 3; + case TEAM_LEAD -> 2; + case USER -> 1; + default -> 0; // Limited users + }; + } + + /** Checks if this role has higher or equal authority than another role */ + public boolean hasAuthorityOver(Role otherRole) { + return this.getHierarchyLevel() >= otherRole.getHierarchyLevel(); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/Organization.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/Organization.java new file mode 100644 index 000000000..bd654899e --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/Organization.java @@ -0,0 +1,45 @@ +package stirling.software.proprietary.model; + +import java.io.Serializable; +import java.util.HashSet; +import java.util.Set; + +import jakarta.persistence.*; + +import lombok.*; + +@Entity +@Table(name = "organizations") +@NoArgsConstructor +@Getter +@Setter +@EqualsAndHashCode(onlyExplicitlyIncluded = true) +@ToString(onlyExplicitlyIncluded = true) +public class Organization implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "org_id") + private Long id; + + @Column(name = "name", unique = true, nullable = false) + private String name; + + @Column(name = "description") + private String description; + + @OneToMany(mappedBy = "organization", cascade = CascadeType.ALL, orphanRemoval = true) + private Set teams = new HashSet<>(); + + public void addTeam(Team team) { + teams.add(team); + team.setOrganization(this); + } + + public void removeTeam(Team team) { + teams.remove(team); + team.setOrganization(null); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/Team.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/Team.java index 5157b3233..66014107f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/model/Team.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/Team.java @@ -11,7 +11,7 @@ import lombok.*; import stirling.software.proprietary.security.model.User; @Entity -@Table(name = "teams") +@Table(name = "teams", uniqueConstraints = @UniqueConstraint(columnNames = {"name", "org_id"})) @NoArgsConstructor @Getter @Setter @@ -26,9 +26,15 @@ public class Team implements Serializable { @Column(name = "team_id") private Long id; - @Column(name = "name", unique = true, nullable = false) + @Column(name = "name", nullable = false) private String name; + @ManyToOne(fetch = FetchType.EAGER) + @JoinColumn( + name = "org_id", + nullable = true) // Nullable for backward compatibility during migration + private Organization organization; + @OneToMany(mappedBy = "team", cascade = CascadeType.ALL, orphanRemoval = true) private Set users = new HashSet<>(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/dto/OrganizationWithTeamCountDTO.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/dto/OrganizationWithTeamCountDTO.java new file mode 100644 index 000000000..5361b8302 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/dto/OrganizationWithTeamCountDTO.java @@ -0,0 +1,15 @@ +package stirling.software.proprietary.model.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class OrganizationWithTeamCountDTO { + private Long id; + private String name; + private String description; + private Long teamCount; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java index 4b09fe0e9..aacdf913e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java @@ -15,9 +15,12 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.enumeration.Role; import stirling.software.common.model.exception.UnsupportedProviderException; +import stirling.software.proprietary.model.Organization; import stirling.software.proprietary.model.Team; import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamRepository; import stirling.software.proprietary.security.service.DatabaseServiceInterface; +import stirling.software.proprietary.security.service.OrganizationService; import stirling.software.proprietary.security.service.TeamService; import stirling.software.proprietary.security.service.UserService; @@ -28,6 +31,8 @@ public class InitialSecuritySetup { private final UserService userService; private final TeamService teamService; + private final OrganizationService organizationService; + private final TeamRepository teamRepository; private final ApplicationProperties applicationProperties; private final DatabaseServiceInterface databaseService; @@ -44,6 +49,8 @@ public class InitialSecuritySetup { } userService.migrateOauth2ToSSO(); + migrateAdminRolesToSystemAdmin(); + assignTeamsToDefaultOrganizationIfMissing(); assignUsersToDefaultTeamIfMissing(); initializeInternalApiUser(); } catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) { @@ -52,6 +59,49 @@ public class InitialSecuritySetup { } } + private void assignTeamsToDefaultOrganizationIfMissing() { + // Find teams without organizations (legacy teams from before org feature) + List teamsWithoutOrg = teamRepository.findTeamsWithoutOrganization(); + + if (teamsWithoutOrg.isEmpty()) { + log.debug("No teams without organizations found - migration not needed"); + return; + } + + log.info( + "Found {} teams without organizations. Starting migration...", + teamsWithoutOrg.size()); + + // Ensure default organizations exist + Organization defaultOrg = organizationService.getOrCreateDefaultOrganization(); + Organization internalOrg = organizationService.getOrCreateInternalOrganization(); + + int migratedCount = 0; + for (Team team : teamsWithoutOrg) { + try { + // Assign internal team to internal org, all others to default org + if (TeamService.INTERNAL_TEAM_NAME.equals(team.getName())) { + team.setOrganization(internalOrg); + log.debug("Assigned team '{}' to internal organization", team.getName()); + } else { + team.setOrganization(defaultOrg); + log.debug("Assigned team '{}' to default organization", team.getName()); + } + teamRepository.save(team); + migratedCount++; + } catch (Exception e) { + log.error( + "Failed to migrate team '{}' to organization: {}", + team.getName(), + e.getMessage()); + } + } + + if (migratedCount > 0) { + log.info("Successfully migrated {} teams to organizations", migratedCount); + } + } + private void assignUsersToDefaultTeamIfMissing() { Team defaultTeam = teamService.getOrCreateDefaultTeam(); Team internalTeam = teamService.getOrCreateInternalTeam(); @@ -86,7 +136,7 @@ public class InitialSecuritySetup { Team team = teamService.getOrCreateDefaultTeam(); userService.saveUser( - initialUsername, initialPassword, team, Role.ADMIN.getRoleId(), false); + initialUsername, initialPassword, team, Role.SYSTEM_ADMIN.getRoleId(), false); log.info("Admin user created: {}", initialUsername); } else { createDefaultAdminUser(); @@ -100,7 +150,7 @@ public class InitialSecuritySetup { if (userService.findByUsernameIgnoreCase(defaultUsername).isEmpty()) { Team team = teamService.getOrCreateDefaultTeam(); userService.saveUser( - defaultUsername, defaultPassword, team, Role.ADMIN.getRoleId(), true); + defaultUsername, defaultPassword, team, Role.SYSTEM_ADMIN.getRoleId(), true); log.info("Default admin user created: {}", defaultUsername); } } @@ -134,4 +184,32 @@ public class InitialSecuritySetup { } userService.syncCustomApiUser(applicationProperties.getSecurity().getCustomGlobalAPIKey()); } + + private void migrateAdminRolesToSystemAdmin() { + List adminUsers = userService.findByRole(Role.ADMIN.getRoleId()); + + if (adminUsers.isEmpty()) { + log.debug("No ROLE_ADMIN users found - migration not needed"); + return; + } + + log.info("Found {} ROLE_ADMIN users. Converting to SYSTEM_ADMIN...", adminUsers.size()); + + int migratedCount = 0; + for (User user : adminUsers) { + try { + user.setUserRole(Role.SYSTEM_ADMIN); + userService.saveUser(user); + log.debug("Converted user '{}' from ROLE_ADMIN to SYSTEM_ADMIN", user.getUsername()); + migratedCount++; + } catch (Exception e) { + log.error("Failed to migrate user '{}' from ROLE_ADMIN to SYSTEM_ADMIN: {}", + user.getUsername(), e.getMessage()); + } + } + + if (migratedCount > 0) { + log.info("Successfully migrated {} users from ROLE_ADMIN to SYSTEM_ADMIN", migratedCount); + } + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java index ab809a037..03bda6fc1 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java @@ -8,6 +8,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.DependsOn; import org.springframework.context.annotation.Lazy; +import org.springframework.core.env.Environment; import org.springframework.security.authentication.ProviderManager; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; @@ -74,6 +75,7 @@ public class SecurityConfiguration { private final GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper; private final RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations; private final OpenSaml4AuthenticationRequestResolver saml2AuthenticationRequestResolver; + private final Environment environment; public SecurityConfiguration( PersistentLoginRepository persistentLoginRepository, @@ -91,7 +93,8 @@ public class SecurityConfiguration { @Autowired(required = false) RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations, @Autowired(required = false) - OpenSaml4AuthenticationRequestResolver saml2AuthenticationRequestResolver) { + OpenSaml4AuthenticationRequestResolver saml2AuthenticationRequestResolver, + Environment environment) { this.userDetailsService = userDetailsService; this.userService = userService; this.loginEnabledValue = loginEnabledValue; @@ -106,6 +109,7 @@ public class SecurityConfiguration { this.oAuth2userAuthoritiesMapper = oAuth2userAuthoritiesMapper; this.saml2RelyingPartyRegistrations = saml2RelyingPartyRegistrations; this.saml2AuthenticationRequestResolver = saml2AuthenticationRequestResolver; + this.environment = environment; } @Bean @@ -129,33 +133,49 @@ public class SecurityConfiguration { new CsrfTokenRequestAttributeHandler(); requestHandler.setCsrfRequestAttributeName(null); http.csrf( - csrf -> - csrf.ignoringRequestMatchers( - request -> { - String apiKey = request.getHeader("X-API-KEY"); - // If there's no API key, don't ignore CSRF + csrf -> { + var csrfConfig = + csrf.ignoringRequestMatchers( + request -> { + String apiKey = request.getHeader("X-API-KEY"); + // If there's no API key, don't ignore CSRF + // (return false) + if (apiKey == null || apiKey.trim().isEmpty()) { + return false; + } + // Validate API key using existing UserService + try { + Optional user = + userService.getUserByApiKey(apiKey); + // If API key is valid, ignore CSRF (return + // true) + // If API key is invalid, don't ignore CSRF // (return false) - if (apiKey == null || apiKey.trim().isEmpty()) { - return false; - } - // Validate API key using existing UserService - try { - Optional user = - userService.getUserByApiKey(apiKey); - // If API key is valid, ignore CSRF (return - // true) - // If API key is invalid, don't ignore CSRF - // (return false) - return user.isPresent(); - } catch (Exception e) { - // If there's any error validating the API - // key, don't ignore CSRF - return false; - } - }) - .csrfTokenRepository(cookieRepo) - .csrfTokenRequestHandler(requestHandler)); + return user.isPresent(); + } catch (Exception e) { + // If there's any error validating the API + // key, don't ignore CSRF + return false; + } + }); + + // Only ignore CSRF for H2 console if H2 console is enabled + if (isH2ConsoleEnabled()) { + csrfConfig = csrfConfig.ignoringRequestMatchers("/h2-console/**"); + } + + csrfConfig + .csrfTokenRepository(cookieRepo) + .csrfTokenRequestHandler(requestHandler); + }); } + + // Allow H2 console frames only if H2 console is enabled + if (isH2ConsoleEnabled()) { + http.headers( + headers -> headers.frameOptions(frameOptions -> frameOptions.sameOrigin())); + } + http.addFilterBefore(rateLimitingFilter(), UsernamePasswordAuthenticationFilter.class); http.addFilterAfter(firstLoginFilter, UsernamePasswordAuthenticationFilter.class); http.sessionManagement( @@ -215,6 +235,9 @@ public class SecurityConfiguration { || trimmedUri.startsWith("/images/") || trimmedUri.startsWith("/public/") || trimmedUri.startsWith("/css/") + || (isH2ConsoleEnabled() + && trimmedUri.startsWith( + "/h2-console/")) || trimmedUri.startsWith("/fonts/") || trimmedUri.startsWith("/js/") || trimmedUri.startsWith( @@ -323,4 +346,8 @@ public class SecurityConfiguration { public PersistentTokenRepository persistentTokenRepository() { return new JPATokenRepositoryImpl(persistentLoginRepository); } + + private boolean isH2ConsoleEnabled() { + return environment.getProperty("spring.h2.console.enabled", Boolean.class, false); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/OrgAdminController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/OrgAdminController.java new file mode 100644 index 000000000..9d26308d4 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/OrgAdminController.java @@ -0,0 +1,216 @@ +package stirling.software.proprietary.security.controller.api; + +import java.util.List; +import java.util.Optional; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import io.swagger.v3.oas.annotations.tags.Tag; + +import jakarta.transaction.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.enumeration.Role; +import stirling.software.proprietary.model.Organization; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.security.config.PremiumEndpoint; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamRepository; +import stirling.software.proprietary.security.service.RoleBasedAuthorizationService; + +@RestController +@RequestMapping("/api/v1/org-admin") +@Tag(name = "Organization Admin", description = "Organization Admin Management APIs") +@Slf4j +@RequiredArgsConstructor +@PremiumEndpoint +public class OrgAdminController { + + private final TeamRepository teamRepository; + private final UserRepository userRepository; + private final RoleBasedAuthorizationService authorizationService; + + /** Get all teams in the org admin's organization */ + @GetMapping("/teams") + public ResponseEntity> getOrganizationTeams() { + if (!authorizationService.canManageOrgTeams()) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + + User currentUser = authorizationService.getCurrentUser(); + if (currentUser == null || currentUser.getOrganization() == null) { + return ResponseEntity.badRequest().build(); + } + + List teams = + teamRepository.findByOrganizationId(currentUser.getOrganization().getId()); + return ResponseEntity.ok(teams); + } + + /** Get all users in the org admin's organization */ + @GetMapping("/users") + public ResponseEntity> getOrganizationUsers() { + if (!authorizationService.canManageOrgUsers()) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + + User currentUser = authorizationService.getCurrentUser(); + if (currentUser == null || currentUser.getOrganization() == null) { + return ResponseEntity.badRequest().build(); + } + + // Get all users in teams belonging to this organization + List orgTeams = + teamRepository.findByOrganizationId(currentUser.getOrganization().getId()); + List orgUsers = + orgTeams.stream().flatMap(team -> team.getUsers().stream()).distinct().toList(); + + return ResponseEntity.ok(orgUsers); + } + + /** Assign a user to a team within the organization */ + @PostMapping("/assign-user-to-team") + @Transactional + public ResponseEntity assignUserToTeam( + @RequestParam("userId") Long userId, @RequestParam("teamId") Long teamId) { + + if (!authorizationService.canManageOrgUsers()) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body("Not authorized to manage organization users"); + } + + Optional userOpt = userRepository.findById(userId); + Optional teamOpt = teamRepository.findById(teamId); + + if (userOpt.isEmpty() || teamOpt.isEmpty()) { + return ResponseEntity.notFound().build(); + } + + User user = userOpt.get(); + Team team = teamOpt.get(); + + if (!authorizationService.canAddUserToTeam(userId, team)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body("Not authorized to add user to this team"); + } + + // Assign user to team + user.setTeam(team); + userRepository.save(user); + + return ResponseEntity.ok().body("User assigned to team successfully"); + } + + /** Promote a user to team lead */ + @PostMapping("/promote-to-team-lead") + @Transactional + public ResponseEntity promoteToTeamLead(@RequestParam("userId") Long userId) { + if (!authorizationService.canManageUser(userId)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body("Not authorized to manage this user"); + } + + if (!authorizationService.canAssignRole(Role.TEAM_LEAD)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body("Not authorized to assign team lead role"); + } + + Optional userOpt = userRepository.findById(userId); + if (userOpt.isEmpty()) { + return ResponseEntity.notFound().build(); + } + + User user = userOpt.get(); + + // User must be in a team to become a team lead + if (user.getTeam() == null) { + return ResponseEntity.badRequest() + .body("User must be assigned to a team before becoming a team lead"); + } + + user.setUserRole(Role.TEAM_LEAD); + userRepository.save(user); + + return ResponseEntity.ok().body("User promoted to team lead successfully"); + } + + /** Demote a team lead to regular user */ + @PostMapping("/demote-from-team-lead") + @Transactional + public ResponseEntity demoteFromTeamLead(@RequestParam("userId") Long userId) { + if (!authorizationService.canManageUser(userId)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body("Not authorized to manage this user"); + } + + Optional userOpt = userRepository.findById(userId); + if (userOpt.isEmpty()) { + return ResponseEntity.notFound().build(); + } + + User user = userOpt.get(); + user.setUserRole(Role.USER); + userRepository.save(user); + + return ResponseEntity.ok().body("User demoted from team lead successfully"); + } + + /** Create a new team in the organization */ + @PostMapping("/create-team") + @Transactional + public ResponseEntity createTeam(@RequestParam("teamName") String teamName) { + if (!authorizationService.canManageOrgTeams()) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body("Not authorized to create teams"); + } + + User currentUser = authorizationService.getCurrentUser(); + if (currentUser == null || currentUser.getOrganization() == null) { + return ResponseEntity.badRequest() + .body("Org admin must be assigned to an organization"); + } + + Organization organization = currentUser.getOrganization(); + + // Check if team name already exists in the organization + if (teamRepository.existsByNameIgnoreCaseAndOrganizationId( + teamName, organization.getId())) { + return ResponseEntity.badRequest() + .body("Team with name '" + teamName + "' already exists in this organization"); + } + + Team newTeam = new Team(); + newTeam.setName(teamName); + newTeam.setOrganization(organization); + + Team savedTeam = teamRepository.save(newTeam); + return ResponseEntity.ok(savedTeam); + } + + /** Remove a user from the organization (removes from their team) */ + @PostMapping("/remove-user") + @Transactional + public ResponseEntity removeUserFromOrganization(@RequestParam("userId") Long userId) { + if (!authorizationService.canRemoveUserFromTeam(userId)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body("Not authorized to remove this user"); + } + + Optional userOpt = userRepository.findById(userId); + if (userOpt.isEmpty()) { + return ResponseEntity.notFound().build(); + } + + User user = userOpt.get(); + user.setTeam(null); + user.setUserRole(Role.USER); // Reset to basic user role + userRepository.save(user); + + return ResponseEntity.ok().body("User removed from organization successfully"); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/OrganizationController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/OrganizationController.java new file mode 100644 index 000000000..b32082044 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/OrganizationController.java @@ -0,0 +1,117 @@ +package stirling.software.proprietary.security.controller.api; + +import java.util.List; +import java.util.Optional; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import lombok.RequiredArgsConstructor; + +import stirling.software.proprietary.model.Organization; +import stirling.software.proprietary.model.dto.OrganizationWithTeamCountDTO; +import stirling.software.proprietary.security.repository.OrganizationRepository; +import stirling.software.proprietary.security.service.OrganizationService; +import stirling.software.proprietary.security.service.RoleBasedAuthorizationService; + +@RestController +@RequestMapping("/api/v1/organizations") +@RequiredArgsConstructor +public class OrganizationController { + + private final OrganizationRepository organizationRepository; + private final OrganizationService organizationService; + private final RoleBasedAuthorizationService authorizationService; + + @GetMapping + public ResponseEntity> getAllOrganizations() { + if (!authorizationService.canManageOrganizations()) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + List organizations = + organizationRepository.findAllOrganizationsWithTeamCount(); + return ResponseEntity.ok(organizations); + } + + @GetMapping("/{id}") + public ResponseEntity getOrganization(@PathVariable Long id) { + Optional organizationOpt = organizationRepository.findById(id); + if (organizationOpt.isEmpty()) { + return ResponseEntity.notFound().build(); + } + + Organization organization = organizationOpt.get(); + if (!authorizationService.canViewOrganization(organization)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + + return ResponseEntity.ok(organization); + } + + @PostMapping + public ResponseEntity createOrganization(@RequestBody Organization organization) { + if (!authorizationService.canManageOrganizations()) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body("Not authorized to create organizations"); + } + + if (organizationRepository.existsByNameIgnoreCase(organization.getName())) { + return ResponseEntity.badRequest() + .body("Organization with name '" + organization.getName() + "' already exists"); + } + Organization savedOrganization = organizationRepository.save(organization); + return ResponseEntity.status(HttpStatus.CREATED).body(savedOrganization); + } + + @PutMapping("/{id}") + public ResponseEntity updateOrganization( + @PathVariable Long id, @RequestBody Organization organization) { + if (!authorizationService.canManageOrganizations()) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body("Not authorized to update organizations"); + } + + Optional existingOrganization = organizationRepository.findById(id); + if (existingOrganization.isEmpty()) { + return ResponseEntity.notFound().build(); + } + + if (organizationRepository.existsByNameIgnoreCase(organization.getName()) + && !existingOrganization.get().getName().equalsIgnoreCase(organization.getName())) { + return ResponseEntity.badRequest() + .body("Organization with name '" + organization.getName() + "' already exists"); + } + + organization.setId(id); + Organization savedOrganization = organizationRepository.save(organization); + return ResponseEntity.ok(savedOrganization); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteOrganization(@PathVariable Long id) { + if (!authorizationService.canManageOrganizations()) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body("Not authorized to delete organizations"); + } + + Optional organization = organizationRepository.findById(id); + if (organization.isEmpty()) { + return ResponseEntity.notFound().build(); + } + + // Prevent deletion of default organizations + if (OrganizationService.DEFAULT_ORG_NAME.equals(organization.get().getName()) + || OrganizationService.INTERNAL_ORG_NAME.equals(organization.get().getName())) { + return ResponseEntity.badRequest().body("Cannot delete system organizations"); + } + + if (!organization.get().getTeams().isEmpty()) { + return ResponseEntity.badRequest() + .body("Cannot delete organization with existing teams"); + } + + organizationRepository.deleteById(id); + return ResponseEntity.ok().build(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/TeamController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/TeamController.java index fa8588e7b..a0c026105 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/TeamController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/TeamController.java @@ -2,7 +2,6 @@ package stirling.software.proprietary.security.controller.api; import java.util.Optional; -import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.view.RedirectView; @@ -14,11 +13,16 @@ import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import stirling.software.proprietary.model.Organization; import stirling.software.proprietary.model.Team; import stirling.software.proprietary.security.config.PremiumEndpoint; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.OrganizationRepository; import stirling.software.proprietary.security.repository.TeamRepository; +import stirling.software.proprietary.security.service.OrganizationService; +import stirling.software.proprietary.security.service.OrganizationValidationService; +import stirling.software.proprietary.security.service.RoleBasedAuthorizationService; import stirling.software.proprietary.security.service.TeamService; @Controller @@ -31,20 +35,33 @@ public class TeamController { private final TeamRepository teamRepository; private final UserRepository userRepository; + private final OrganizationRepository organizationRepository; + private final OrganizationService organizationService; + private final OrganizationValidationService organizationValidationService; + private final RoleBasedAuthorizationService authorizationService; - @PreAuthorize("hasRole('ROLE_ADMIN')") @PostMapping("/create") - public RedirectView createTeam(@RequestParam("name") String name) { - if (teamRepository.existsByNameIgnoreCase(name)) { + public RedirectView createTeam( + @RequestParam("name") String name, + @RequestParam("organizationId") Long organizationId) { + if (!authorizationService.canManageOrgTeams()) { + return new RedirectView("/teams?messageType=accessDenied"); + } + Organization organization = organizationService.getOrCreateDefaultOrganization(); + if (organizationId != null) { + organization = organizationRepository.findById(organizationId).orElse(organization); + } + + if (teamRepository.existsByNameIgnoreCaseAndOrganizationId(name, organization.getId())) { return new RedirectView("/teams?messageType=teamExists"); } Team team = new Team(); team.setName(name); + team.setOrganization(organization); teamRepository.save(team); return new RedirectView("/teams?messageType=teamCreated"); } - @PreAuthorize("hasRole('ROLE_ADMIN')") @PostMapping("/rename") public RedirectView renameTeam( @RequestParam("teamId") Long teamId, @RequestParam("newName") String newName) { @@ -52,10 +69,16 @@ public class TeamController { if (existing.isEmpty()) { return new RedirectView("/teams?messageType=teamNotFound"); } - if (teamRepository.existsByNameIgnoreCase(newName)) { + Team team = existing.get(); + + if (!authorizationService.canManageTeam(team)) { + return new RedirectView("/teams?messageType=accessDenied"); + } + + if (teamRepository.existsByNameIgnoreCaseAndOrganizationId( + newName, team.getOrganization().getId())) { return new RedirectView("/teams?messageType=teamNameExists"); } - Team team = existing.get(); // Prevent renaming the Internal team if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) { @@ -67,7 +90,6 @@ public class TeamController { return new RedirectView("/teams?messageType=teamRenamed"); } - @PreAuthorize("hasRole('ROLE_ADMIN')") @PostMapping("/delete") @Transactional public RedirectView deleteTeam(@RequestParam("teamId") Long teamId) { @@ -78,6 +100,10 @@ public class TeamController { Team team = teamOpt.get(); + if (!authorizationService.canManageTeam(team)) { + return new RedirectView("/teams?messageType=accessDenied"); + } + // Prevent deleting the Internal team if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) { return new RedirectView("/teams?messageType=internalTeamNotAccessible"); @@ -92,7 +118,6 @@ public class TeamController { return new RedirectView("/teams?messageType=teamDeleted"); } - @PreAuthorize("hasRole('ROLE_ADMIN')") @PostMapping("/addUser") @Transactional public RedirectView addUserToTeam( @@ -104,6 +129,10 @@ public class TeamController { .findById(teamId) .orElseThrow(() -> new RuntimeException("Team not found")); + if (!authorizationService.canAddUserToTeam(userId, team)) { + return new RedirectView("/teams/" + teamId + "?error=accessDenied"); + } + // Prevent adding users to the Internal team if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) { return new RedirectView("/teams?error=internalTeamNotAccessible"); @@ -121,6 +150,13 @@ public class TeamController { return new RedirectView("/teams/" + teamId + "?error=cannotMoveInternalUsers"); } + // Ensure user and team are in the same organization (or user has no org yet) + if (user.getOrganization() != null + && !organizationValidationService.isTeamInOrganization( + team, user.getOrganization())) { + return new RedirectView("/teams/" + teamId + "?error=userNotInSameOrganization"); + } + // Assign user to team user.setTeam(team); userRepository.save(user); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/TeamLeadController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/TeamLeadController.java new file mode 100644 index 000000000..5963c3fb1 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/TeamLeadController.java @@ -0,0 +1,187 @@ +package stirling.software.proprietary.security.controller.api; + +import java.util.List; +import java.util.Optional; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import io.swagger.v3.oas.annotations.tags.Tag; + +import jakarta.transaction.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.enumeration.Role; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.security.config.PremiumEndpoint; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamRepository; +import stirling.software.proprietary.security.service.RoleBasedAuthorizationService; + +@RestController +@RequestMapping("/api/v1/team-lead") +@Tag(name = "Team Lead", description = "Team Lead Management APIs") +@Slf4j +@RequiredArgsConstructor +@PremiumEndpoint +public class TeamLeadController { + + private final TeamRepository teamRepository; + private final UserRepository userRepository; + private final RoleBasedAuthorizationService authorizationService; + + /** Get team members that the current team lead can manage */ + @GetMapping("/my-team-members") + public ResponseEntity> getMyTeamMembers() { + if (!authorizationService.canManageTeamUsers()) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + + User currentUser = authorizationService.getCurrentUser(); + if (currentUser == null || currentUser.getTeam() == null) { + return ResponseEntity.badRequest().build(); + } + + List teamMembers = userRepository.findByTeam(currentUser.getTeam()); + return ResponseEntity.ok(teamMembers); + } + + /** Add a user to the team lead's team */ + @PostMapping("/add-member") + @Transactional + public ResponseEntity addMemberToMyTeam(@RequestParam("userId") Long userId) { + User currentUser = authorizationService.getCurrentUser(); + if (currentUser == null || currentUser.getTeam() == null) { + return ResponseEntity.badRequest().body("Team lead must be assigned to a team"); + } + + if (!authorizationService.canAddUserToTeam(userId, currentUser.getTeam())) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body("Not authorized to add users to this team"); + } + + Optional userOpt = userRepository.findById(userId); + if (userOpt.isEmpty()) { + return ResponseEntity.notFound().build(); + } + + User user = userOpt.get(); + + // Check if user is already in a team + if (user.getTeam() != null) { + return ResponseEntity.badRequest() + .body("User is already assigned to team: " + user.getTeam().getName()); + } + + // Assign user to team + user.setTeam(currentUser.getTeam()); + userRepository.save(user); + + return ResponseEntity.ok().body("User added to team successfully"); + } + + /** Remove a user from the team lead's team */ + @PostMapping("/remove-member") + @Transactional + public ResponseEntity removeMemberFromMyTeam(@RequestParam("userId") Long userId) { + if (!authorizationService.canRemoveUserFromTeam(userId)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body("Not authorized to remove this user"); + } + + Optional userOpt = userRepository.findById(userId); + if (userOpt.isEmpty()) { + return ResponseEntity.notFound().build(); + } + + User user = userOpt.get(); + User currentUser = authorizationService.getCurrentUser(); + + // Prevent team leads from removing themselves + if (currentUser != null && currentUser.getId().equals(userId)) { + return ResponseEntity.badRequest() + .body("Team leads cannot remove themselves from the team"); + } + + // Remove user from team + user.setTeam(null); + userRepository.save(user); + + return ResponseEntity.ok().body("User removed from team successfully"); + } + + /** Get users that can be added to the team (within same organization, not in any team) */ + @GetMapping("/available-users") + public ResponseEntity> getAvailableUsers() { + if (!authorizationService.canManageTeamUsers()) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + + User currentUser = authorizationService.getCurrentUser(); + if (currentUser == null || currentUser.getOrganization() == null) { + return ResponseEntity.badRequest().build(); + } + + // Find users in the same organization who are not in any team + List availableUsers = + userRepository.findUsersInOrganizationWithoutTeam( + currentUser.getOrganization().getId()); + + return ResponseEntity.ok(availableUsers); + } + + /** Update a team member's role (team leads can only assign USER role) */ + @PostMapping("/update-member-role") + @Transactional + public ResponseEntity updateMemberRole( + @RequestParam("userId") Long userId, @RequestParam("role") String roleString) { + + if (!authorizationService.canManageUser(userId)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body("Not authorized to manage this user"); + } + + try { + Role newRole = Role.fromString(roleString); + + // Team leads can only assign USER role + if (!authorizationService.canAssignRole(newRole)) { + return ResponseEntity.badRequest() + .body("Not authorized to assign role: " + newRole.getRoleName()); + } + + Optional userOpt = userRepository.findById(userId); + if (userOpt.isEmpty()) { + return ResponseEntity.notFound().build(); + } + + User user = userOpt.get(); + user.setUserRole(newRole); + userRepository.save(user); + + return ResponseEntity.ok().body("User role updated successfully"); + + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body("Invalid role: " + roleString); + } + } + + /** Get team information for the current team lead */ + @GetMapping("/my-team") + public ResponseEntity getMyTeam() { + User currentUser = authorizationService.getCurrentUser(); + if (currentUser == null || currentUser.getTeam() == null) { + return ResponseEntity.badRequest().build(); + } + + if (!authorizationService.canManageTeam(currentUser.getTeam())) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + + return ResponseEntity.ok(currentUser.getTeam()); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java index 4d74dbfd8..e0a366b8d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java @@ -36,5 +36,13 @@ public interface UserRepository extends JpaRepository { long countByTeam(Team team); + List findByTeam(Team team); + List findAllByTeam(Team team); + + @Query("SELECT u FROM User u WHERE u.team IS NULL") + List findUsersInOrganizationWithoutTeam(@Param("organizationId") Long organizationId); + + @Query("SELECT u FROM User u JOIN u.authorities a WHERE a.authority = :role") + List findByRole(@Param("role") String role); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java index d3e232f61..9b0b06dc5 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java @@ -102,4 +102,88 @@ public class User implements Serializable { public boolean hasPassword() { return this.password != null && !this.password.isEmpty(); } + + public stirling.software.proprietary.model.Organization getOrganization() { + return this.team != null ? this.team.getOrganization() : null; + } + + // Role-based permission methods + public Role getUserRole() { + String roleString = getRolesAsString(); + if (roleString == null || roleString.isEmpty()) return Role.USER; + + try { + return Role.fromString(roleString); + } catch (IllegalArgumentException e) { + return Role.USER; // Default fallback + } + } + + public boolean isSystemAdmin() { + Role role = getUserRole(); + return role.isSystemAdmin(); + } + + public boolean isOrgAdmin() { + Role role = getUserRole(); + return role.isOrgAdmin(); + } + + public boolean isTeamLead() { + Role role = getUserRole(); + return role.isTeamLead(); + } + + public boolean canManageUser(User otherUser) { + // System admins can manage anyone + if (isSystemAdmin()) return true; + + // Org admins can manage users in their organization + if (isOrgAdmin()) { + stirling.software.proprietary.model.Organization thisOrg = getOrganization(); + stirling.software.proprietary.model.Organization otherOrg = otherUser.getOrganization(); + return thisOrg != null && otherOrg != null && thisOrg.getId().equals(otherOrg.getId()); + } + + // Team leads can manage users in their team + if (isTeamLead()) { + return this.team != null + && otherUser.team != null + && this.team.getId().equals(otherUser.team.getId()); + } + + return false; + } + + public boolean canManageTeam(stirling.software.proprietary.model.Team targetTeam) { + if (targetTeam == null) return false; + + // System admins can manage any team + if (isSystemAdmin()) return true; + + // Org admins can manage teams in their organization + if (isOrgAdmin()) { + stirling.software.proprietary.model.Organization thisOrg = getOrganization(); + stirling.software.proprietary.model.Organization teamOrg = targetTeam.getOrganization(); + return thisOrg != null && teamOrg != null && thisOrg.getId().equals(teamOrg.getId()); + } + + // Team leads can only manage their own team + if (isTeamLead()) { + return this.team != null && this.team.getId().equals(targetTeam.getId()); + } + + return false; + } + + public void setUserRole(Role role) { + // Clear existing authorities + this.authorities.clear(); + + // Add new authority + Authority authority = new Authority(); + authority.setAuthority(role.getRoleId()); + authority.setUser(this); + this.authorities.add(authority); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/OrganizationRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/OrganizationRepository.java new file mode 100644 index 000000000..4885aed98 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/OrganizationRepository.java @@ -0,0 +1,23 @@ +package stirling.software.proprietary.security.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import stirling.software.proprietary.model.Organization; +import stirling.software.proprietary.model.dto.OrganizationWithTeamCountDTO; + +@Repository +public interface OrganizationRepository extends JpaRepository { + Optional findByName(String name); + + @Query( + "SELECT new stirling.software.proprietary.model.dto.OrganizationWithTeamCountDTO(o.id, o.name, o.description, COUNT(t)) " + + "FROM Organization o LEFT JOIN o.teams t GROUP BY o.id, o.name, o.description") + List findAllOrganizationsWithTeamCount(); + + boolean existsByNameIgnoreCase(String name); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/TeamRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/TeamRepository.java index ccf72be0a..4a665180c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/TeamRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/TeamRepository.java @@ -14,10 +14,24 @@ import stirling.software.proprietary.model.dto.TeamWithUserCountDTO; public interface TeamRepository extends JpaRepository { Optional findByName(String name); + Optional findByNameAndOrganizationId(String name, Long organizationId); + + List findByOrganizationId(Long organizationId); + + @Query( + "SELECT new stirling.software.proprietary.model.dto.TeamWithUserCountDTO(t.id, t.name, COUNT(u)) " + + "FROM Team t LEFT JOIN t.users u WHERE t.organization.id = :organizationId GROUP BY t.id, t.name") + List findAllTeamsWithUserCountByOrganizationId(Long organizationId); + @Query( "SELECT new stirling.software.proprietary.model.dto.TeamWithUserCountDTO(t.id, t.name, COUNT(u)) " + "FROM Team t LEFT JOIN t.users u GROUP BY t.id, t.name") List findAllTeamsWithUserCount(); boolean existsByNameIgnoreCase(String name); + + boolean existsByNameIgnoreCaseAndOrganizationId(String name, Long organizationId); + + @Query("SELECT t FROM Team t WHERE t.organization IS NULL") + List findTeamsWithoutOrganization(); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/OrganizationService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/OrganizationService.java new file mode 100644 index 000000000..660f8da72 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/OrganizationService.java @@ -0,0 +1,43 @@ +package stirling.software.proprietary.security.service; + +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; + +import stirling.software.proprietary.model.Organization; +import stirling.software.proprietary.security.repository.OrganizationRepository; + +@Service +@RequiredArgsConstructor +public class OrganizationService { + + private final OrganizationRepository organizationRepository; + + public static final String DEFAULT_ORG_NAME = "Default Organization"; + public static final String INTERNAL_ORG_NAME = "Internal Organization"; + + public Organization getOrCreateDefaultOrganization() { + return organizationRepository + .findByName(DEFAULT_ORG_NAME) + .orElseGet( + () -> { + Organization defaultOrg = new Organization(); + defaultOrg.setName(DEFAULT_ORG_NAME); + defaultOrg.setDescription("Default organization for initial setup"); + return organizationRepository.save(defaultOrg); + }); + } + + public Organization getOrCreateInternalOrganization() { + return organizationRepository + .findByName(INTERNAL_ORG_NAME) + .orElseGet( + () -> { + Organization internalOrg = new Organization(); + internalOrg.setName(INTERNAL_ORG_NAME); + internalOrg.setDescription( + "Internal organization for system operations"); + return organizationRepository.save(internalOrg); + }); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/OrganizationValidationService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/OrganizationValidationService.java new file mode 100644 index 000000000..064e95eef --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/OrganizationValidationService.java @@ -0,0 +1,79 @@ +package stirling.software.proprietary.security.service; + +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; + +import stirling.software.proprietary.model.Organization; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamRepository; + +@Service +@RequiredArgsConstructor +public class OrganizationValidationService { + + private final TeamRepository teamRepository; + + /** + * Validates that a user has access to a specific team. Users can only access teams within their + * own organization. + */ + public boolean canUserAccessTeam(User user, Team team) { + if (user == null || team == null) { + return false; + } + + Organization userOrg = user.getOrganization(); + Organization teamOrg = team.getOrganization(); + + if (userOrg == null || teamOrg == null) { + return false; + } + + return userOrg.getId().equals(teamOrg.getId()); + } + + /** Validates that a user has access to a specific team by ID. */ + public boolean canUserAccessTeam(User user, Long teamId) { + if (user == null || teamId == null) { + return false; + } + + Organization userOrg = user.getOrganization(); + if (userOrg == null) { + return false; + } + + return teamRepository + .findById(teamId) + .map(team -> userOrg.getId().equals(team.getOrganization().getId())) + .orElse(false); + } + + /** Validates that two users belong to the same organization. */ + public boolean areUsersInSameOrganization(User user1, User user2) { + if (user1 == null || user2 == null) { + return false; + } + + Organization org1 = user1.getOrganization(); + Organization org2 = user2.getOrganization(); + + if (org1 == null || org2 == null) { + return false; + } + + return org1.getId().equals(org2.getId()); + } + + /** Validates that a team belongs to a specific organization. */ + public boolean isTeamInOrganization(Team team, Organization organization) { + if (team == null || organization == null) { + return false; + } + + Organization teamOrg = team.getOrganization(); + return teamOrg != null && teamOrg.getId().equals(organization.getId()); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/RoleBasedAuthorizationService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/RoleBasedAuthorizationService.java new file mode 100644 index 000000000..cbba69e85 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/RoleBasedAuthorizationService.java @@ -0,0 +1,128 @@ +package stirling.software.proprietary.security.service; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; + +import stirling.software.common.model.enumeration.Role; +import stirling.software.proprietary.model.Organization; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.User; + +@Service +@RequiredArgsConstructor +public class RoleBasedAuthorizationService { + + private final UserRepository userRepository; + + /** Gets the current authenticated user */ + public User getCurrentUser() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated()) { + return null; + } + + String username = authentication.getName(); + return userRepository.findByUsername(username).orElse(null); + } + + /** Checks if current user can manage users across all organizations (System Admin) */ + public boolean canManageAllUsers() { + User currentUser = getCurrentUser(); + return currentUser != null && currentUser.isSystemAdmin(); + } + + /** Checks if current user can manage users within their organization (Org Admin or above) */ + public boolean canManageOrgUsers() { + User currentUser = getCurrentUser(); + return currentUser != null && currentUser.isOrgAdmin(); + } + + /** Checks if current user can manage team members (Team Lead or above) */ + public boolean canManageTeamUsers() { + User currentUser = getCurrentUser(); + return currentUser != null && currentUser.isTeamLead(); + } + + /** Checks if current user can manage a specific user */ + public boolean canManageUser(Long userId) { + User currentUser = getCurrentUser(); + if (currentUser == null) return false; + + User targetUser = userRepository.findById(userId).orElse(null); + if (targetUser == null) return false; + + return currentUser.canManageUser(targetUser); + } + + /** Checks if current user can manage a specific team */ + public boolean canManageTeam(Team team) { + User currentUser = getCurrentUser(); + if (currentUser == null || team == null) return false; + + return currentUser.canManageTeam(team); + } + + /** Checks if current user can manage teams within their organization */ + public boolean canManageOrgTeams() { + User currentUser = getCurrentUser(); + return currentUser != null && currentUser.isOrgAdmin(); + } + + /** Checks if current user can create/manage organizations (System Admin only) */ + public boolean canManageOrganizations() { + return canManageAllUsers(); + } + + /** Checks if current user can assign roles */ + public boolean canAssignRole(Role targetRole) { + User currentUser = getCurrentUser(); + if (currentUser == null) return false; + + // Users can only assign roles that are lower than or equal to their own + return currentUser.getUserRole().hasAuthorityOver(targetRole); + } + + /** Checks if current user can remove a user from their team/organization */ + public boolean canRemoveUserFromTeam(Long userId) { + return canManageUser(userId); + } + + /** Checks if current user can add a user to a specific team */ + public boolean canAddUserToTeam(Long userId, Team team) { + User currentUser = getCurrentUser(); + if (currentUser == null || team == null) return false; + + // Must be able to manage both the user and the target team + return canManageUser(userId) && currentUser.canManageTeam(team); + } + + /** Gets the highest role the current user can assign to others */ + public Role getMaxAssignableRole() { + User currentUser = getCurrentUser(); + if (currentUser == null) return Role.USER; + + return switch (currentUser.getUserRole()) { + case SYSTEM_ADMIN, ADMIN -> Role.ORG_ADMIN; // System admins can create org admins + case ORG_ADMIN -> Role.TEAM_LEAD; // Org admins can create team leads + case TEAM_LEAD -> Role.USER; // Team leads can only create regular users + default -> Role.USER; + }; + } + + /** Checks if current user can view organization details */ + public boolean canViewOrganization(Organization organization) { + User currentUser = getCurrentUser(); + if (currentUser == null || organization == null) return false; + + // System admins can view any org + if (currentUser.isSystemAdmin()) return true; + + // Org admins and team leads can view their own organization + Organization userOrg = currentUser.getOrganization(); + return userOrg != null && userOrg.getId().equals(organization.getId()); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/TeamService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/TeamService.java index 194a2a967..c9dfe8601 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/TeamService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/TeamService.java @@ -4,6 +4,7 @@ import org.springframework.stereotype.Service; import lombok.RequiredArgsConstructor; +import stirling.software.proprietary.model.Organization; import stirling.software.proprietary.model.Team; import stirling.software.proprietary.security.repository.TeamRepository; @@ -12,29 +13,46 @@ import stirling.software.proprietary.security.repository.TeamRepository; public class TeamService { private final TeamRepository teamRepository; + private final OrganizationService organizationService; public static final String DEFAULT_TEAM_NAME = "Default"; public static final String INTERNAL_TEAM_NAME = "Internal"; public Team getOrCreateDefaultTeam() { + Organization defaultOrg = organizationService.getOrCreateDefaultOrganization(); return teamRepository - .findByName(DEFAULT_TEAM_NAME) + .findByNameAndOrganizationId(DEFAULT_TEAM_NAME, defaultOrg.getId()) .orElseGet( () -> { Team defaultTeam = new Team(); defaultTeam.setName(DEFAULT_TEAM_NAME); + defaultTeam.setOrganization(defaultOrg); return teamRepository.save(defaultTeam); }); } public Team getOrCreateInternalTeam() { + Organization internalOrg = organizationService.getOrCreateInternalOrganization(); return teamRepository - .findByName(INTERNAL_TEAM_NAME) + .findByNameAndOrganizationId(INTERNAL_TEAM_NAME, internalOrg.getId()) .orElseGet( () -> { Team internalTeam = new Team(); internalTeam.setName(INTERNAL_TEAM_NAME); + internalTeam.setOrganization(internalOrg); return teamRepository.save(internalTeam); }); } + + public Team getOrCreateTeamForOrganization(String teamName, Organization organization) { + return teamRepository + .findByNameAndOrganizationId(teamName, organization.getId()) + .orElseGet( + () -> { + Team team = new Team(); + team.setName(teamName); + team.setOrganization(organization); + return teamRepository.save(team); + }); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java index 50c8027f6..0bae1c7b2 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java @@ -328,6 +328,10 @@ public class UserService implements UserServiceInterface { return userRepository.findByUsernameIgnoreCaseWithSettings(username); } + public List findByRole(String role) { + return userRepository.findByRole(role); + } + public Authority findRole(User user) { return authorityRepository.findByUserId(user.getId()); } @@ -626,4 +630,8 @@ public class UserService implements UserServiceInterface { public void saveAll(List users) { userRepository.saveAll(users); } + + public User saveUser(User user) { + return userRepository.save(user); + } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/TeamServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/TeamServiceTest.java index 264c0b59a..8488b59a5 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/TeamServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/TeamServiceTest.java @@ -6,10 +6,13 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import stirling.software.proprietary.model.Organization; import stirling.software.proprietary.model.Team; import stirling.software.proprietary.security.repository.TeamRepository; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -18,15 +21,24 @@ class TeamServiceTest { @Mock private TeamRepository teamRepository; + @Mock + private OrganizationService organizationService; + @InjectMocks private TeamService teamService; @Test void getDefaultTeam() { + var organization = new Organization(); + organization.setId(1L); + organization.setName("Default Organization"); + var team = new Team(); team.setName("Marleyans"); + team.setOrganization(organization); - when(teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME)) + when(organizationService.getOrCreateDefaultOrganization()).thenReturn(organization); + when(teamRepository.findByNameAndOrganizationId(TeamService.DEFAULT_TEAM_NAME, organization.getId())) .thenReturn(Optional.of(team)); Team result = teamService.getOrCreateDefaultTeam(); @@ -36,12 +48,18 @@ class TeamServiceTest { @Test void createDefaultTeam_whenRepositoryIsEmpty() { + var organization = new Organization(); + organization.setId(1L); + organization.setName("Default Organization"); + String teamName = "Default"; var defaultTeam = new Team(); defaultTeam.setId(1L); defaultTeam.setName(teamName); + defaultTeam.setOrganization(organization); - when(teamRepository.findByName(teamName)) + when(organizationService.getOrCreateDefaultOrganization()).thenReturn(organization); + when(teamRepository.findByNameAndOrganizationId(teamName, organization.getId())) .thenReturn(Optional.empty()); when(teamRepository.save(any(Team.class))).thenReturn(defaultTeam); @@ -52,10 +70,16 @@ class TeamServiceTest { @Test void getInternalTeam() { + var organization = new Organization(); + organization.setId(2L); + organization.setName("Internal Organization"); + var team = new Team(); team.setName("Eldians"); + team.setOrganization(organization); - when(teamRepository.findByName(TeamService.INTERNAL_TEAM_NAME)) + when(organizationService.getOrCreateInternalOrganization()).thenReturn(organization); + when(teamRepository.findByNameAndOrganizationId(TeamService.INTERNAL_TEAM_NAME, organization.getId())) .thenReturn(Optional.of(team)); Team result = teamService.getOrCreateInternalTeam(); @@ -65,16 +89,20 @@ class TeamServiceTest { @Test void createInternalTeam_whenRepositoryIsEmpty() { + var organization = new Organization(); + organization.setId(2L); + organization.setName("Internal Organization"); + String teamName = "Internal"; Team internalTeam = new Team(); internalTeam.setId(2L); internalTeam.setName(teamName); + internalTeam.setOrganization(organization); - when(teamRepository.findByName(teamName)) + when(organizationService.getOrCreateInternalOrganization()).thenReturn(organization); + when(teamRepository.findByNameAndOrganizationId(TeamService.INTERNAL_TEAM_NAME, organization.getId())) .thenReturn(Optional.empty()); when(teamRepository.save(any(Team.class))).thenReturn(internalTeam); - when(teamRepository.findByName(TeamService.INTERNAL_TEAM_NAME)) - .thenReturn(Optional.empty()); Team result = teamService.getOrCreateInternalTeam();