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
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>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}.</p>
*
* @version 1.0
* @since 31.03.26
*/
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>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}.</p>
*
* @version 1.0
* @since 31.03.26
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@
/**
* Entity representing an assignment of a permission profile to a user.
*
* <p>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.</p>
*
* <p>Lombok generates standard accessors/mutators, a no-args constructor,
* an all-args constructor, and a builder for this entity.</p>
*
* @version 1.0
* @since 31.03.26
*/
Expand All @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,27 @@
* Repository interface for managing UserProfileAssignment entities.
* Provides methods to perform CRUD operations and custom queries related to user profile assignments.
*
* <p>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.</p>
*
* @version 1.0
* @since 31.03.26
*/
public interface UserProfileAssignmentRepository extends JpaRepository<UserProfileAssignment, Long> {

/**
* Finds assignments for a user that are active at a specific timestamp.
*
* <p>An assignment is considered active when:</p>
* <ul>
* <li>{@code startsAt} is null or less than/equal to {@code at}</li>
* <li>{@code endsAt} is null or greater than/equal to {@code at}</li>
* </ul>
*
* @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
Expand All @@ -26,14 +42,32 @@ public interface UserProfileAssignmentRepository extends JpaRepository<UserProfi
""")
List<UserProfileAssignment> 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.
*
* <p>Global assignments are those where {@code location} is null.</p>
*
* @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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(),
Expand Down
Loading
Loading