Skip to content

Commit 3efc09f

Browse files
authored
Merge pull request #37 from Stcwal/test/permission
Test/permission
2 parents d0cbccd + fe02752 commit 3efc09f

7 files changed

Lines changed: 329 additions & 3 deletions

File tree

backend/src/main/java/backend/fullstack/permission/profile/PermissionProfile.java

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@
2525
*
2626
* 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.
2727
*
28+
* <p>Lombok generates standard accessors/mutators, a no-args constructor, an all-args constructor,
29+
* and a builder for this entity. When not explicitly set in the builder, {@code isActive}
30+
* defaults to {@code true}.</p>
31+
*
2832
* @version 1.0
2933
* @since 31.03.26
3034
*/
@@ -42,27 +46,49 @@
4246
@Builder
4347
public class PermissionProfile {
4448

49+
/**
50+
* Surrogate primary key for this profile.
51+
*/
4552
@Id
4653
@GeneratedValue(strategy = GenerationType.IDENTITY)
4754
private Long id;
4855

56+
/**
57+
* Organization that owns this permission profile.
58+
*/
4959
@ManyToOne(fetch = FetchType.LAZY, optional = false)
5060
@JoinColumn(name = "organization_id", nullable = false)
5161
private Organization organization;
5262

63+
/**
64+
* Human-readable profile name, unique within one organization.
65+
*/
5366
@Column(name = "name", nullable = false, length = 120)
5467
private String name;
5568

69+
/**
70+
* Optional description of the profile's intended use.
71+
*/
5672
@Column(name = "description", length = 255)
5773
private String description;
5874

75+
/**
76+
* Flag indicating whether the profile is active and assignable.
77+
* Defaults to {@code true} when omitted in the builder.
78+
*/
5979
@Column(name = "is_active", nullable = false)
6080
@Builder.Default
6181
private boolean isActive = true;
6282

83+
/**
84+
* Creation timestamp set automatically when the entity is first persisted.
85+
*/
6386
@Column(name = "created_at", nullable = false, updatable = false)
6487
private LocalDateTime createdAt;
6588

89+
/**
90+
* Lifecycle callback that assigns a creation timestamp before first persist.
91+
*/
6692
@PrePersist
6793
protected void onCreate() {
6894
this.createdAt = LocalDateTime.now();

backend/src/main/java/backend/fullstack/permission/profile/PermissionProfileBinding.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@
2525
* 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.
2626
*
2727
* 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.
28+
*
29+
* <p>The entity is typically created via Lombok's generated builder. When values are not provided,
30+
* {@code scope} defaults to {@link PermissionScope#ORGANIZATION} and {@code conditionType} defaults
31+
* to {@link PermissionConditionType#NONE}.</p>
2832
*
2933
* @version 1.0
3034
* @since 31.03.26
@@ -46,26 +50,46 @@
4650
@Builder
4751
public class PermissionProfileBinding {
4852

53+
/**
54+
* Surrogate primary key for this binding.
55+
*/
4956
@Id
5057
@GeneratedValue(strategy = GenerationType.IDENTITY)
5158
private Long id;
5259

60+
/**
61+
* The permission profile that owns this binding.
62+
*/
5363
@ManyToOne(fetch = FetchType.LAZY, optional = false)
5464
@JoinColumn(name = "profile_id", nullable = false)
5565
private PermissionProfile profile;
5666

67+
/**
68+
* The permission key granted by this binding.
69+
*/
5770
@Enumerated(EnumType.STRING)
5871
@Column(name = "permission_key", nullable = false, length = 80)
5972
private Permission permission;
6073

74+
/**
75+
* Scope for where the permission applies.
76+
* Defaults to {@link PermissionScope#ORGANIZATION} when omitted in the builder.
77+
*/
6178
@Enumerated(EnumType.STRING)
6279
@Column(name = "scope", nullable = false, length = 20)
6380
@Builder.Default
6481
private PermissionScope scope = PermissionScope.ORGANIZATION;
6582

83+
/**
84+
* Optional location identifier used when {@link #scope} is location-scoped.
85+
*/
6686
@Column(name = "location_id")
6787
private Long locationId;
6888

89+
/**
90+
* Optional runtime condition attached to this binding.
91+
* Defaults to {@link PermissionConditionType#NONE} when omitted in the builder.
92+
*/
6993
@Enumerated(EnumType.STRING)
7094
@Column(name = "condition_type", nullable = false, length = 30)
7195
@Builder.Default

backend/src/main/java/backend/fullstack/permission/profile/UserProfileAssignment.java

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@
2323
/**
2424
* Entity representing an assignment of a permission profile to a user.
2525
*
26+
* <p>An assignment links a user to one profile, either organization-wide (no location)
27+
* or location-scoped (specific location). The optional start and end timestamps define
28+
* when the assignment is active.</p>
29+
*
30+
* <p>Lombok generates standard accessors/mutators, a no-args constructor,
31+
* an all-args constructor, and a builder for this entity.</p>
32+
*
2633
* @version 1.0
2734
* @since 31.03.26
2835
*/
@@ -40,25 +47,46 @@
4047
@Builder
4148
public class UserProfileAssignment {
4249

50+
/**
51+
* Surrogate primary key for this assignment.
52+
*/
4353
@Id
4454
@GeneratedValue(strategy = GenerationType.IDENTITY)
4555
private Long id;
4656

57+
/**
58+
* User receiving the permission profile assignment.
59+
*/
4760
@ManyToOne(fetch = FetchType.LAZY, optional = false)
4861
@JoinColumn(name = "user_id", nullable = false)
4962
private User user;
5063

64+
/**
65+
* Permission profile being assigned.
66+
*/
5167
@ManyToOne(fetch = FetchType.LAZY, optional = false)
5268
@JoinColumn(name = "profile_id", nullable = false)
5369
private PermissionProfile profile;
5470

55-
@ManyToOne(fetch = FetchType.LAZY)
56-
@JoinColumn(name = "location_id")
57-
private Location location;
71+
/**
72+
* Optional location for location-scoped assignments.
73+
* If null, the assignment is treated as organization-scoped.
74+
*/
75+
@ManyToOne(fetch = FetchType.LAZY)
76+
@JoinColumn(name = "location_id")
77+
private Location location;
5878

79+
/**
80+
* Optional timestamp from when the assignment becomes active.
81+
* If null, the assignment is active immediately.
82+
*/
5983
@Column(name = "starts_at")
6084
private LocalDateTime startsAt;
6185

86+
/**
87+
* Optional timestamp when the assignment expires.
88+
* If null, the assignment remains active until explicitly ended.
89+
*/
6290
@Column(name = "ends_at")
6391
private LocalDateTime endsAt;
6492
}

backend/src/main/java/backend/fullstack/permission/profile/UserProfileAssignmentRepository.java

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,27 @@
1212
* Repository interface for managing UserProfileAssignment entities.
1313
* Provides methods to perform CRUD operations and custom queries related to user profile assignments.
1414
*
15+
* <p>Custom queries in this repository are used to resolve active assignments at a point in time
16+
* and to remove assignment groups by user and scope.</p>
17+
*
1518
* @version 1.0
1619
* @since 31.03.26
1720
*/
1821
public interface UserProfileAssignmentRepository extends JpaRepository<UserProfileAssignment, Long> {
1922

23+
/**
24+
* Finds assignments for a user that are active at a specific timestamp.
25+
*
26+
* <p>An assignment is considered active when:</p>
27+
* <ul>
28+
* <li>{@code startsAt} is null or less than/equal to {@code at}</li>
29+
* <li>{@code endsAt} is null or greater than/equal to {@code at}</li>
30+
* </ul>
31+
*
32+
* @param userId the user identifier
33+
* @param at the instant used to evaluate assignment activity
34+
* @return active assignments for the user at the provided timestamp
35+
*/
2036
@Query("""
2137
SELECT a
2238
FROM UserProfileAssignment a
@@ -26,14 +42,32 @@ public interface UserProfileAssignmentRepository extends JpaRepository<UserProfi
2642
""")
2743
List<UserProfileAssignment> findActiveByUserId(@Param("userId") Long userId, @Param("at") LocalDateTime at);
2844

45+
/**
46+
* Deletes all profile assignments for the specified user.
47+
*
48+
* @param userId the user identifier
49+
*/
2950
@Modifying
3051
@Query("DELETE FROM UserProfileAssignment a WHERE a.user.id = :userId")
3152
void deleteAllByUserId(@Param("userId") Long userId);
3253

54+
/**
55+
* Deletes all profile assignments for the specified user at a specific location.
56+
*
57+
* @param userId the user identifier
58+
* @param locationId the location identifier
59+
*/
3360
@Modifying
3461
@Query("DELETE FROM UserProfileAssignment a WHERE a.user.id = :userId AND a.location.id = :locationId")
3562
void deleteAllByUserIdAndLocationId(@Param("userId") Long userId, @Param("locationId") Long locationId);
3663

64+
/**
65+
* Deletes all organization-scoped (global) assignments for the specified user.
66+
*
67+
* <p>Global assignments are those where {@code location} is null.</p>
68+
*
69+
* @param userId the user identifier
70+
*/
3771
@Modifying
3872
@Query("DELETE FROM UserProfileAssignment a WHERE a.user.id = :userId AND a.location IS NULL")
3973
void deleteAllGlobalByUserId(@Param("userId") Long userId);

backend/src/test/java/backend/fullstack/permission/AuthorizationServiceTest.java

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,14 @@
3131
import backend.fullstack.permission.catalog.RolePermissionCatalog;
3232
import backend.fullstack.permission.core.AuthorizationService;
3333
import backend.fullstack.permission.core.ConditionEvaluator;
34+
import backend.fullstack.permission.core.PermissionConditionContext;
3435
import backend.fullstack.permission.model.Permission;
3536
import backend.fullstack.permission.override.UserPermissionOverrideRepository;
3637
import backend.fullstack.permission.profile.PermissionProfileBindingRepository;
3738
import backend.fullstack.permission.profile.UserProfileAssignmentRepository;
3839
import backend.fullstack.permission.dto.CapabilitiesResponse;
3940
import backend.fullstack.training.TrainingRecordRepository;
41+
import backend.fullstack.training.TrainingType;
4042
import backend.fullstack.user.AccessContextService;
4143
import backend.fullstack.user.User;
4244
import backend.fullstack.user.UserLocationScopeAssignmentRepository;
@@ -219,6 +221,80 @@ void getCurrentCapabilitiesIncludesPerLocationCapabilities() {
219221
assertTrue(response.getPermissionScopeLocationIds().containsKey("checklists.read"));
220222
}
221223

224+
@Test
225+
void hasPermissionWithConditionRespectsTrainingRequirement() {
226+
User actor = user(1L, 100L, Role.MANAGER, "actor@everest.no");
227+
authenticate(actor);
228+
229+
when(userRepository.findById(actor.getId())).thenReturn(java.util.Optional.of(actor));
230+
when(userRepository.findEffectiveLocationScopeByUserId(actor.getId())).thenReturn(List.of(7L));
231+
when(userLocationScopeAssignmentRepository.findActiveLocationIdsByUserId(eq(actor.getId()), any()))
232+
.thenReturn(List.of());
233+
rolePermissionCatalog.setEffectivePermissions(7L, Set.of(Permission.LOGS_FREEZER_CREATE));
234+
when(trainingRecordRepository.hasValidTraining(eq(actor.getId()), eq(TrainingType.FREEZER_LOGGING), any()))
235+
.thenReturn(false);
236+
237+
boolean denied = authorizationService.hasPermissionWithCondition(
238+
Permission.LOGS_FREEZER_CREATE,
239+
7L,
240+
new PermissionConditionContext(true, false)
241+
);
242+
243+
assertFalse(denied);
244+
245+
when(trainingRecordRepository.hasValidTraining(eq(actor.getId()), eq(TrainingType.FREEZER_LOGGING), any()))
246+
.thenReturn(true);
247+
248+
boolean allowed = authorizationService.hasPermissionWithCondition(
249+
Permission.LOGS_FREEZER_CREATE,
250+
7L,
251+
new PermissionConditionContext(true, false)
252+
);
253+
254+
assertTrue(allowed);
255+
}
256+
257+
@Test
258+
void assertCanAssignLocationsRejectsSupervisorOutsideScope() {
259+
User supervisor = user(10L, 100L, Role.SUPERVISOR, "supervisor@everest.no");
260+
User targetStaff = user(11L, 100L, Role.STAFF, "staff@everest.no");
261+
262+
authenticate(supervisor);
263+
when(userRepository.findById(supervisor.getId())).thenReturn(java.util.Optional.of(supervisor));
264+
when(userRepository.findEffectiveLocationScopeByUserId(supervisor.getId())).thenReturn(List.of(7L));
265+
when(userLocationScopeAssignmentRepository.findActiveLocationIdsByUserId(eq(supervisor.getId()), any()))
266+
.thenReturn(List.of());
267+
when(userRepository.findAdditionalLocationIdsByUserId(supervisor.getId())).thenReturn(List.of(7L));
268+
when(userRepository.findAdditionalLocationIdsByUserId(targetStaff.getId())).thenReturn(List.of(7L));
269+
rolePermissionCatalog.setEffectivePermissions(null,
270+
Set.of(Permission.USERS_ASSIGN_LOCATIONS, Permission.USERS_UPDATE));
271+
272+
backend.fullstack.exceptions.AccessDeniedException ex =
273+
assertThrows(backend.fullstack.exceptions.AccessDeniedException.class,
274+
() -> authorizationService.assertCanAssignLocations(targetStaff, List.of(7L, 8L)));
275+
276+
assertTrue(ex.getMessage().contains("outside your scope"));
277+
}
278+
279+
@Test
280+
void assertCanDeactivateUserRejectsManagerEvenWithPermission() {
281+
User manager = user(20L, 100L, Role.MANAGER, "manager@everest.no");
282+
User targetStaff = user(21L, 100L, Role.STAFF, "target-staff@everest.no");
283+
284+
authenticate(manager);
285+
when(userRepository.findById(manager.getId())).thenReturn(java.util.Optional.of(manager));
286+
when(userRepository.findAdditionalLocationIdsByUserId(manager.getId())).thenReturn(List.of(9L));
287+
when(userRepository.findAdditionalLocationIdsByUserId(targetStaff.getId())).thenReturn(List.of(9L));
288+
rolePermissionCatalog.setEffectivePermissions(null,
289+
Set.of(Permission.USERS_DEACTIVATE, Permission.USERS_UPDATE));
290+
291+
backend.fullstack.exceptions.AccessDeniedException ex =
292+
assertThrows(backend.fullstack.exceptions.AccessDeniedException.class,
293+
() -> authorizationService.assertCanDeactivateUser(targetStaff));
294+
295+
assertTrue(ex.getMessage().contains("Only ADMIN or SUPERVISOR can deactivate users"));
296+
}
297+
222298
private static void authenticate(User actor) {
223299
JwtPrincipal principal = new JwtPrincipal(
224300
actor.getId(),

0 commit comments

Comments
 (0)