diff --git a/backend/src/main/java/backend/fullstack/permission/profile/PermissionProfile.java b/backend/src/main/java/backend/fullstack/permission/profile/PermissionProfile.java index 727b080..f57788e 100644 --- a/backend/src/main/java/backend/fullstack/permission/profile/PermissionProfile.java +++ b/backend/src/main/java/backend/fullstack/permission/profile/PermissionProfile.java @@ -25,6 +25,10 @@ * * Each permission profile belongs to a specific organization and has a unique name within that organization. The profile can be active or inactive, allowing for flexible management of user permissions over time. * + *

Lombok generates standard accessors/mutators, a no-args constructor, an all-args constructor, + * and a builder for this entity. When not explicitly set in the builder, {@code isActive} + * defaults to {@code true}.

+ * * @version 1.0 * @since 31.03.26 */ @@ -42,27 +46,49 @@ @Builder public class PermissionProfile { + /** + * Surrogate primary key for this profile. + */ @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + /** + * Organization that owns this permission profile. + */ @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "organization_id", nullable = false) private Organization organization; + /** + * Human-readable profile name, unique within one organization. + */ @Column(name = "name", nullable = false, length = 120) private String name; + /** + * Optional description of the profile's intended use. + */ @Column(name = "description", length = 255) private String description; + /** + * Flag indicating whether the profile is active and assignable. + * Defaults to {@code true} when omitted in the builder. + */ @Column(name = "is_active", nullable = false) @Builder.Default private boolean isActive = true; + /** + * Creation timestamp set automatically when the entity is first persisted. + */ @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; + /** + * Lifecycle callback that assigns a creation timestamp before first persist. + */ @PrePersist protected void onCreate() { this.createdAt = LocalDateTime.now(); diff --git a/backend/src/main/java/backend/fullstack/permission/profile/PermissionProfileBinding.java b/backend/src/main/java/backend/fullstack/permission/profile/PermissionProfileBinding.java index 54ec09e..22ec66b 100644 --- a/backend/src/main/java/backend/fullstack/permission/profile/PermissionProfileBinding.java +++ b/backend/src/main/java/backend/fullstack/permission/profile/PermissionProfileBinding.java @@ -25,6 +25,10 @@ * Entity representing the binding of a permission to a permission profile. This defines which permissions are included in each profile, along with the scope and any conditions that apply to the permission. * * Each instance of PermissionProfileBinding represents a single permission assignment to a specific profile. The combination of profile, permission, scope, location, and condition type is unique, ensuring that a profile cannot have duplicate bindings for the same permission and scope. + * + *

The entity is typically created via Lombok's generated builder. When values are not provided, + * {@code scope} defaults to {@link PermissionScope#ORGANIZATION} and {@code conditionType} defaults + * to {@link PermissionConditionType#NONE}.

* * @version 1.0 * @since 31.03.26 @@ -46,26 +50,46 @@ @Builder public class PermissionProfileBinding { + /** + * Surrogate primary key for this binding. + */ @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + /** + * The permission profile that owns this binding. + */ @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "profile_id", nullable = false) private PermissionProfile profile; + /** + * The permission key granted by this binding. + */ @Enumerated(EnumType.STRING) @Column(name = "permission_key", nullable = false, length = 80) private Permission permission; + /** + * Scope for where the permission applies. + * Defaults to {@link PermissionScope#ORGANIZATION} when omitted in the builder. + */ @Enumerated(EnumType.STRING) @Column(name = "scope", nullable = false, length = 20) @Builder.Default private PermissionScope scope = PermissionScope.ORGANIZATION; + /** + * Optional location identifier used when {@link #scope} is location-scoped. + */ @Column(name = "location_id") private Long locationId; + /** + * Optional runtime condition attached to this binding. + * Defaults to {@link PermissionConditionType#NONE} when omitted in the builder. + */ @Enumerated(EnumType.STRING) @Column(name = "condition_type", nullable = false, length = 30) @Builder.Default diff --git a/backend/src/main/java/backend/fullstack/permission/profile/UserProfileAssignment.java b/backend/src/main/java/backend/fullstack/permission/profile/UserProfileAssignment.java index cfb84ed..e647c86 100644 --- a/backend/src/main/java/backend/fullstack/permission/profile/UserProfileAssignment.java +++ b/backend/src/main/java/backend/fullstack/permission/profile/UserProfileAssignment.java @@ -23,6 +23,13 @@ /** * Entity representing an assignment of a permission profile to a user. * + *

An assignment links a user to one profile, either organization-wide (no location) + * or location-scoped (specific location). The optional start and end timestamps define + * when the assignment is active.

+ * + *

Lombok generates standard accessors/mutators, a no-args constructor, + * an all-args constructor, and a builder for this entity.

+ * * @version 1.0 * @since 31.03.26 */ @@ -40,25 +47,46 @@ @Builder public class UserProfileAssignment { + /** + * Surrogate primary key for this assignment. + */ @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + /** + * User receiving the permission profile assignment. + */ @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "user_id", nullable = false) private User user; + /** + * Permission profile being assigned. + */ @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "profile_id", nullable = false) private PermissionProfile profile; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "location_id") - private Location location; + /** + * Optional location for location-scoped assignments. + * If null, the assignment is treated as organization-scoped. + */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "location_id") + private Location location; + /** + * Optional timestamp from when the assignment becomes active. + * If null, the assignment is active immediately. + */ @Column(name = "starts_at") private LocalDateTime startsAt; + /** + * Optional timestamp when the assignment expires. + * If null, the assignment remains active until explicitly ended. + */ @Column(name = "ends_at") private LocalDateTime endsAt; } diff --git a/backend/src/main/java/backend/fullstack/permission/profile/UserProfileAssignmentRepository.java b/backend/src/main/java/backend/fullstack/permission/profile/UserProfileAssignmentRepository.java index 0281f2c..3ba35e9 100644 --- a/backend/src/main/java/backend/fullstack/permission/profile/UserProfileAssignmentRepository.java +++ b/backend/src/main/java/backend/fullstack/permission/profile/UserProfileAssignmentRepository.java @@ -12,11 +12,27 @@ * Repository interface for managing UserProfileAssignment entities. * Provides methods to perform CRUD operations and custom queries related to user profile assignments. * + *

Custom queries in this repository are used to resolve active assignments at a point in time + * and to remove assignment groups by user and scope.

+ * * @version 1.0 * @since 31.03.26 */ public interface UserProfileAssignmentRepository extends JpaRepository { + /** + * Finds assignments for a user that are active at a specific timestamp. + * + *

An assignment is considered active when:

+ * + * + * @param userId the user identifier + * @param at the instant used to evaluate assignment activity + * @return active assignments for the user at the provided timestamp + */ @Query(""" SELECT a FROM UserProfileAssignment a @@ -26,14 +42,32 @@ public interface UserProfileAssignmentRepository extends JpaRepository findActiveByUserId(@Param("userId") Long userId, @Param("at") LocalDateTime at); + /** + * Deletes all profile assignments for the specified user. + * + * @param userId the user identifier + */ @Modifying @Query("DELETE FROM UserProfileAssignment a WHERE a.user.id = :userId") void deleteAllByUserId(@Param("userId") Long userId); + /** + * Deletes all profile assignments for the specified user at a specific location. + * + * @param userId the user identifier + * @param locationId the location identifier + */ @Modifying @Query("DELETE FROM UserProfileAssignment a WHERE a.user.id = :userId AND a.location.id = :locationId") void deleteAllByUserIdAndLocationId(@Param("userId") Long userId, @Param("locationId") Long locationId); + /** + * Deletes all organization-scoped (global) assignments for the specified user. + * + *

Global assignments are those where {@code location} is null.

+ * + * @param userId the user identifier + */ @Modifying @Query("DELETE FROM UserProfileAssignment a WHERE a.user.id = :userId AND a.location IS NULL") void deleteAllGlobalByUserId(@Param("userId") Long userId); diff --git a/backend/src/test/java/backend/fullstack/permission/AuthorizationServiceTest.java b/backend/src/test/java/backend/fullstack/permission/AuthorizationServiceTest.java index c1bd7ba..09a93a1 100644 --- a/backend/src/test/java/backend/fullstack/permission/AuthorizationServiceTest.java +++ b/backend/src/test/java/backend/fullstack/permission/AuthorizationServiceTest.java @@ -31,12 +31,14 @@ import backend.fullstack.permission.catalog.RolePermissionCatalog; import backend.fullstack.permission.core.AuthorizationService; import backend.fullstack.permission.core.ConditionEvaluator; +import backend.fullstack.permission.core.PermissionConditionContext; import backend.fullstack.permission.model.Permission; import backend.fullstack.permission.override.UserPermissionOverrideRepository; import backend.fullstack.permission.profile.PermissionProfileBindingRepository; import backend.fullstack.permission.profile.UserProfileAssignmentRepository; import backend.fullstack.permission.dto.CapabilitiesResponse; import backend.fullstack.training.TrainingRecordRepository; +import backend.fullstack.training.TrainingType; import backend.fullstack.user.AccessContextService; import backend.fullstack.user.User; import backend.fullstack.user.UserLocationScopeAssignmentRepository; @@ -219,6 +221,80 @@ void getCurrentCapabilitiesIncludesPerLocationCapabilities() { assertTrue(response.getPermissionScopeLocationIds().containsKey("checklists.read")); } + @Test + void hasPermissionWithConditionRespectsTrainingRequirement() { + User actor = user(1L, 100L, Role.MANAGER, "actor@everest.no"); + authenticate(actor); + + when(userRepository.findById(actor.getId())).thenReturn(java.util.Optional.of(actor)); + when(userRepository.findEffectiveLocationScopeByUserId(actor.getId())).thenReturn(List.of(7L)); + when(userLocationScopeAssignmentRepository.findActiveLocationIdsByUserId(eq(actor.getId()), any())) + .thenReturn(List.of()); + rolePermissionCatalog.setEffectivePermissions(7L, Set.of(Permission.LOGS_FREEZER_CREATE)); + when(trainingRecordRepository.hasValidTraining(eq(actor.getId()), eq(TrainingType.FREEZER_LOGGING), any())) + .thenReturn(false); + + boolean denied = authorizationService.hasPermissionWithCondition( + Permission.LOGS_FREEZER_CREATE, + 7L, + new PermissionConditionContext(true, false) + ); + + assertFalse(denied); + + when(trainingRecordRepository.hasValidTraining(eq(actor.getId()), eq(TrainingType.FREEZER_LOGGING), any())) + .thenReturn(true); + + boolean allowed = authorizationService.hasPermissionWithCondition( + Permission.LOGS_FREEZER_CREATE, + 7L, + new PermissionConditionContext(true, false) + ); + + assertTrue(allowed); + } + + @Test + void assertCanAssignLocationsRejectsSupervisorOutsideScope() { + User supervisor = user(10L, 100L, Role.SUPERVISOR, "supervisor@everest.no"); + User targetStaff = user(11L, 100L, Role.STAFF, "staff@everest.no"); + + authenticate(supervisor); + when(userRepository.findById(supervisor.getId())).thenReturn(java.util.Optional.of(supervisor)); + when(userRepository.findEffectiveLocationScopeByUserId(supervisor.getId())).thenReturn(List.of(7L)); + when(userLocationScopeAssignmentRepository.findActiveLocationIdsByUserId(eq(supervisor.getId()), any())) + .thenReturn(List.of()); + when(userRepository.findAdditionalLocationIdsByUserId(supervisor.getId())).thenReturn(List.of(7L)); + when(userRepository.findAdditionalLocationIdsByUserId(targetStaff.getId())).thenReturn(List.of(7L)); + rolePermissionCatalog.setEffectivePermissions(null, + Set.of(Permission.USERS_ASSIGN_LOCATIONS, Permission.USERS_UPDATE)); + + backend.fullstack.exceptions.AccessDeniedException ex = + assertThrows(backend.fullstack.exceptions.AccessDeniedException.class, + () -> authorizationService.assertCanAssignLocations(targetStaff, List.of(7L, 8L))); + + assertTrue(ex.getMessage().contains("outside your scope")); + } + + @Test + void assertCanDeactivateUserRejectsManagerEvenWithPermission() { + User manager = user(20L, 100L, Role.MANAGER, "manager@everest.no"); + User targetStaff = user(21L, 100L, Role.STAFF, "target-staff@everest.no"); + + authenticate(manager); + when(userRepository.findById(manager.getId())).thenReturn(java.util.Optional.of(manager)); + when(userRepository.findAdditionalLocationIdsByUserId(manager.getId())).thenReturn(List.of(9L)); + when(userRepository.findAdditionalLocationIdsByUserId(targetStaff.getId())).thenReturn(List.of(9L)); + rolePermissionCatalog.setEffectivePermissions(null, + Set.of(Permission.USERS_DEACTIVATE, Permission.USERS_UPDATE)); + + backend.fullstack.exceptions.AccessDeniedException ex = + assertThrows(backend.fullstack.exceptions.AccessDeniedException.class, + () -> authorizationService.assertCanDeactivateUser(targetStaff)); + + assertTrue(ex.getMessage().contains("Only ADMIN or SUPERVISOR can deactivate users")); + } + private static void authenticate(User actor) { JwtPrincipal principal = new JwtPrincipal( actor.getId(), diff --git a/backend/src/test/java/backend/fullstack/permission/PermissionBootstrapSeederTest.java b/backend/src/test/java/backend/fullstack/permission/PermissionBootstrapSeederTest.java new file mode 100644 index 0000000..22a8da8 --- /dev/null +++ b/backend/src/test/java/backend/fullstack/permission/PermissionBootstrapSeederTest.java @@ -0,0 +1,96 @@ +package backend.fullstack.permission; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.boot.ApplicationArguments; + +import backend.fullstack.permission.catalog.RolePermissionBinding; +import backend.fullstack.permission.catalog.RolePermissionBindingRepository; +import backend.fullstack.permission.definition.PermissionDefinition; +import backend.fullstack.permission.definition.PermissionDefinitionRepository; +import backend.fullstack.permission.model.Permission; +import backend.fullstack.user.role.Role; + +@ExtendWith(MockitoExtension.class) +class PermissionBootstrapSeederTest { + + @Mock + private PermissionDefinitionRepository permissionDefinitionRepository; + + @Mock + private RolePermissionBindingRepository rolePermissionBindingRepository; + + private PermissionBootstrapSeeder seeder; + + @BeforeEach + void setUp() { + seeder = new PermissionBootstrapSeeder(permissionDefinitionRepository, rolePermissionBindingRepository); + } + + @Test + void runCreatesMissingPermissionDefinitions() { + when(permissionDefinitionRepository.findByPermissionKey(anyString())).thenReturn(Optional.empty()); + when(permissionDefinitionRepository.save(any(PermissionDefinition.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + when(rolePermissionBindingRepository.existsByRole(any(Role.class))).thenReturn(true); + + seeder.run(mock(ApplicationArguments.class)); + + ArgumentCaptor definitionCaptor = ArgumentCaptor.forClass(PermissionDefinition.class); + verify(permissionDefinitionRepository, times(Permission.values().length)).save(definitionCaptor.capture()); + verify(rolePermissionBindingRepository, never()).save(any(RolePermissionBinding.class)); + + Set savedKeys = definitionCaptor.getAllValues().stream() + .map(PermissionDefinition::getPermissionKey) + .collect(Collectors.toSet()); + + assertEquals(Permission.values().length, savedKeys.size()); + } + + @Test + void runSeedsBindingsOnlyForMissingRoleBaselines() { + when(permissionDefinitionRepository.findByPermissionKey(anyString())) + .thenAnswer(invocation -> Optional.of( + PermissionDefinition.builder().permissionKey(invocation.getArgument(0)).build() + )); + when(rolePermissionBindingRepository.existsByRole(any(Role.class))) + .thenAnswer(invocation -> invocation.getArgument(0) != Role.MANAGER); + + seeder.run(mock(ApplicationArguments.class)); + + verify(permissionDefinitionRepository, never()).save(any(PermissionDefinition.class)); + verify(rolePermissionBindingRepository, atLeastOnce()) + .save(any(RolePermissionBinding.class)); + verify(rolePermissionBindingRepository, never()) + .save(org.mockito.ArgumentMatchers.argThat(binding -> binding.getRole() == Role.ADMIN)); + verify(rolePermissionBindingRepository, atLeastOnce()) + .save(org.mockito.ArgumentMatchers.argThat(binding -> binding.getRole() == Role.MANAGER)); + } + + @Test + void runSwallowsRuntimeExceptionsFromRepositories() { + when(permissionDefinitionRepository.findByPermissionKey(anyString())) + .thenThrow(new RuntimeException("db not available")); + + assertDoesNotThrow(() -> seeder.run(mock(ApplicationArguments.class))); + } +} diff --git a/backend/src/test/java/backend/fullstack/permission/profile/PermissionProfileTest.java b/backend/src/test/java/backend/fullstack/permission/profile/PermissionProfileTest.java new file mode 100644 index 0000000..ab8ad69 --- /dev/null +++ b/backend/src/test/java/backend/fullstack/permission/profile/PermissionProfileTest.java @@ -0,0 +1,42 @@ +package backend.fullstack.permission.profile; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class PermissionProfileTest { + + @Test + void builderDefaultsToActive() { + PermissionProfile profile = PermissionProfile.builder() + .name("Shift Leader") + .description("Can approve checklist") + .build(); + + assertTrue(profile.isActive()); + } + + @Test + void builderAllowsExplicitInactive() { + PermissionProfile profile = PermissionProfile.builder() + .name("Legacy") + .isActive(false) + .build(); + + assertFalse(profile.isActive()); + } + + @Test + void onCreateSetsCreatedTimestamp() { + PermissionProfile profile = PermissionProfile.builder() + .name("Kitchen") + .build(); + + assertNull(profile.getCreatedAt()); + profile.onCreate(); + assertNotNull(profile.getCreatedAt()); + } +}