Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added README.md
Empty file.
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ public AuthService(
*/
public User registerBootstrapAdmin(RegisterRequest request) {
if (userRepository.count() > 0) {
throw new AccessDeniedException("Public registration is disabled after bootstrap");
throw new AccessDeniedException("Bootstrap registration is only available before the first user is created");
}

if (request.getRole() != Role.ADMIN) {
Expand Down
28 changes: 19 additions & 9 deletions backend/src/main/java/backend/fullstack/config/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,18 @@
public class SecurityConfig {

private final JwtAuthFilter jwtAuthFilter;
private final SecurityErrorHandler securityErrorHandler;
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;

public SecurityConfig(
JwtAuthFilter jwtAuthFilter,
SecurityErrorHandler securityErrorHandler,
UserRepository userRepository,
PasswordEncoder passwordEncoder
) {
this.jwtAuthFilter = jwtAuthFilter;
this.securityErrorHandler = securityErrorHandler;
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
Expand All @@ -46,12 +49,17 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.exceptionHandling(exceptions -> exceptions
.authenticationEntryPoint(securityErrorHandler)
.accessDeniedHandler(securityErrorHandler)
)
.authenticationProvider(authenticationProvider())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/error").permitAll()
.requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll()
.requestMatchers(HttpMethod.POST, "/api/auth/login", "/api/auth/logout").permitAll()
.requestMatchers(HttpMethod.POST, "/api/auth/register").access(this::canAccessRegister)
.requestMatchers(HttpMethod.POST, "/api/auth/register", "/api/organization")
.access(this::canAccessBootstrapSetup)
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
Expand All @@ -78,20 +86,22 @@ public AuthenticationManager authenticationManager(AuthenticationConfiguration c
return configuration.getAuthenticationManager();
}

private AuthorizationDecision canAccessRegister(
private AuthorizationDecision canAccessBootstrapSetup(
Supplier<Authentication> authentication,
RequestAuthorizationContext context
) {
return new AuthorizationDecision(isRegisterAccessAllowed(authentication.get()));
return new AuthorizationDecision(isBootstrapSetupAllowed(authentication.get()));
}

boolean isRegisterAccessAllowed(Authentication auth) {
if (userRepository.count() == 0) {
return true;
}
return isBootstrapSetupAllowed(auth);
}

boolean isOrganizationCreateAccessAllowed(Authentication auth) {
return isBootstrapSetupAllowed(auth);
}

return auth != null
&& auth.isAuthenticated()
&& auth.getAuthorities().stream().anyMatch(a -> "ROLE_ADMIN".equals(a.getAuthority()));
private boolean isBootstrapSetupAllowed(Authentication auth) {
return userRepository.count() == 0;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package backend.fullstack.config;

import java.io.IOException;
import java.time.LocalDateTime;

import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

import com.fasterxml.jackson.databind.ObjectMapper;

import backend.fullstack.dto.ErrorResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

/**
* Writes security-layer authentication and authorization failures using the
* same JSON error envelope as controller exceptions.
*
* @version 1.0
* @since 04.04.26
*/
@Component
public class SecurityErrorHandler implements AuthenticationEntryPoint, AccessDeniedHandler {

private final ObjectMapper objectMapper;

public SecurityErrorHandler(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}

@Override
public void commence(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException
) throws IOException {
writeErrorResponse(
response,
HttpStatus.UNAUTHORIZED,
"UNAUTHORIZED",
authException.getMessage() == null ? "Authentication is required" : authException.getMessage(),
request.getRequestURI()
);
}

@Override
public void handle(
HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException
) throws IOException {
writeErrorResponse(
response,
HttpStatus.FORBIDDEN,
"ACCESS_DENIED",
accessDeniedException.getMessage() == null ? "Access denied" : accessDeniedException.getMessage(),
request.getRequestURI()
);
}

private void writeErrorResponse(
HttpServletResponse response,
HttpStatus status,
String errorCode,
String message,
String path
) throws IOException {
ErrorResponse errorResponse = new ErrorResponse(
LocalDateTime.now(),
status.value(),
status.getReasonPhrase(),
errorCode,
message,
null,
path
);

response.setStatus(status.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
objectMapper.writeValue(response.getOutputStream(), errorResponse);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,18 @@

import java.util.List;

/**
* Controller for managing locations within an organization.
* Endpoints:
* - POST /api/locations: Create a new location.
* - GET /api/locations: Get all accessible locations.
* - GET /api/locations/{id}: Get location by ID.
* - PUT /api/locations/{id}: Update location.
* - DELETE /api/locations/{id}: Delete location.
*
* @version 1.0
* @since 03.04.26
*/
@RestController
@RequestMapping("/api/locations")
@RequiredArgsConstructor
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
/**
* Service class for managing locations within an organization.
*
* @version 1.0
* @version 1.1
* @since 28.03.26
*/
@Service
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@
/**
* Service class for managing organizations.
*
* @version 1.0
* @since 28.03.26
* @version 1.1
* @since 03.04.26
*/
@Service
@RequiredArgsConstructor
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package backend.fullstack.permission;

import java.util.List;
import java.util.Map;
import java.util.Set;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import backend.fullstack.user.role.Role;

/**
* Component responsible for seeding the permission system with initial data on application startup.
*
* @version 1.0
* @since 31.03.26
*/
@Component
public class PermissionBootstrapSeeder implements ApplicationRunner {

private static final Logger logger = LoggerFactory.getLogger(PermissionBootstrapSeeder.class);

private final PermissionDefinitionRepository permissionDefinitionRepository;
private final RolePermissionBindingRepository rolePermissionBindingRepository;

public PermissionBootstrapSeeder(
PermissionDefinitionRepository permissionDefinitionRepository,
RolePermissionBindingRepository rolePermissionBindingRepository
) {
this.permissionDefinitionRepository = permissionDefinitionRepository;
this.rolePermissionBindingRepository = rolePermissionBindingRepository;
}

/**
* Seeds the permission system with initial data on application startup.
*
* @param args the application arguments
*/
@Override
@Transactional
public void run(ApplicationArguments args) {
try {
seedPermissionDefinitions();
seedRolePermissions();
} catch (RuntimeException ex) {
// Startup should not fail solely because permission seed cannot run.
logger.warn("Permission bootstrap seed skipped due to repository/database issue", ex);
}
}

/**
* Seeds the permission definitions with initial data.
*/
private void seedPermissionDefinitions() {
for (Permission permission : Permission.values()) {
permissionDefinitionRepository.findByPermissionKey(permission.key())
.orElseGet(() -> permissionDefinitionRepository.save(
PermissionDefinition.builder()
.permissionKey(permission.key())
.description(permission.key())
.build()
));
}
}

/**
* Seeds the role permissions with initial data.
*/
private void seedRolePermissions() {
Map<Role, Set<Permission>> defaults = DefaultRolePermissionMatrix.create();

for (Map.Entry<Role, Set<Permission>> entry : defaults.entrySet()) {
Role role = entry.getKey();
if (rolePermissionBindingRepository.existsByRole(role)) {
continue;
}

for (Permission permission : entry.getValue()) {
PermissionDefinition definition = permissionDefinitionRepository
.findByPermissionKey(permission.key())
.orElseThrow(() -> new IllegalStateException("Permission definition missing: " + permission.key()));

rolePermissionBindingRepository.save(
RolePermissionBinding.builder()
.role(role)
.permission(definition)
.build()
);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package backend.fullstack.permission;

/**
* Enum representing the scope of a permission.
*/
public enum PermissionScope {
ORGANIZATION,
LOCATION
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ public static Map<Role, Set<Permission>> create() {
Permission.LOGS_TEMPERATURE_READ,
Permission.CHECKLISTS_READ,
Permission.DEVIATIONS_READ,
Permission.DEVIATIONS_RESOLVE,
Permission.REPORTS_READ,
Permission.REPORTS_EXPORT
));
Expand All @@ -49,9 +50,13 @@ public static Map<Role, Set<Permission>> create() {
Permission.LOGS_TEMPERATURE_CREATE,
Permission.CHECKLISTS_READ,
Permission.CHECKLISTS_COMPLETE,
Permission.CHECKLISTS_APPROVE,
Permission.CHECKLISTS_TEMPLATE_MANAGE,
Permission.DEVIATIONS_READ,
Permission.DEVIATIONS_CREATE,
Permission.REPORTS_READ
Permission.DEVIATIONS_RESOLVE,
Permission.REPORTS_READ,
Permission.REPORTS_EXPORT
));

mapping.put(Role.STAFF, EnumSet.of(
Expand Down
Loading
Loading