Unified secman test (#2606)

* Unified Security Management Test

Signed-off-by: Avgustin Marinov <Avgustin.Marinov@bosch.com>

* Add unified ManagementSecurityTest

Signed-off-by: Avgustin Marinov <Avgustin.Marinov@bosch.com>

---------

Signed-off-by: Avgustin Marinov <Avgustin.Marinov@bosch.com>
This commit is contained in:
Avgustin Marinov
2025-08-13 12:12:16 +03:00
committed by GitHub
parent c5bbbeaac7
commit 8abf7275c4
9 changed files with 538 additions and 113 deletions

View File

@@ -14,7 +14,6 @@ import static org.eclipse.hawkbit.mgmt.rest.resource.util.PagingUtility.sanitize
import java.text.MessageFormat; import java.text.MessageFormat;
import java.util.AbstractMap.SimpleEntry; import java.util.AbstractMap.SimpleEntry;
import java.util.Collection; import java.util.Collection;
import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Map.Entry; import java.util.Map.Entry;
@@ -69,7 +68,6 @@ import org.eclipse.hawkbit.security.SystemSecurityContext;
import org.eclipse.hawkbit.utils.TenantConfigHelper; import org.eclipse.hawkbit.utils.TenantConfigHelper;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@@ -316,7 +314,8 @@ public class MgmtDistributionSetResource implements MgmtDistributionSetRestApi {
final int pagingOffsetParam, final int pagingLimitParam, final String sortParam) { final int pagingOffsetParam, final int pagingLimitParam, final String sortParam) {
final Pageable pageable = PagingUtility.toPageable(pagingOffsetParam, pagingLimitParam, sanitizeDistributionSetSortParam(sortParam)); final Pageable pageable = PagingUtility.toPageable(pagingOffsetParam, pagingLimitParam, sanitizeDistributionSetSortParam(sortParam));
final Page<? extends SoftwareModule> softwareModules = softwareModuleManagement.findByAssignedTo(distributionSetId, pageable); final Page<? extends SoftwareModule> softwareModules = softwareModuleManagement.findByAssignedTo(distributionSetId, pageable);
return ResponseEntity.ok(new PagedList<>(MgmtSoftwareModuleMapper.toResponse(softwareModules.getContent()), softwareModules.getTotalElements())); return ResponseEntity.ok(
new PagedList<>(MgmtSoftwareModuleMapper.toResponse(softwareModules.getContent()), softwareModules.getTotalElements()));
} }
@Override @Override
@@ -357,10 +356,9 @@ public class MgmtDistributionSetResource implements MgmtDistributionSetRestApi {
@AuditLog(entity = "DistributionSet", type = AuditLog.Type.DELETE, description = "Invalidate Distribution Set") @AuditLog(entity = "DistributionSet", type = AuditLog.Type.DELETE, description = "Invalidate Distribution Set")
public ResponseEntity<Void> invalidateDistributionSet( public ResponseEntity<Void> invalidateDistributionSet(
final Long distributionSetId, final MgmtInvalidateDistributionSetRequestBody invalidateRequestBody) { final Long distributionSetId, final MgmtInvalidateDistributionSetRequestBody invalidateRequestBody) {
distributionSetInvalidationManagement distributionSetInvalidationManagement.invalidateDistributionSet(new DistributionSetInvalidation(
.invalidateDistributionSet( List.of(distributionSetId),
new DistributionSetInvalidation(Collections.singletonList(distributionSetId), MgmtRestModelMapper.convertCancelationType(invalidateRequestBody.getActionCancelationType())));
MgmtRestModelMapper.convertCancelationType(invalidateRequestBody.getActionCancelationType())));
return ResponseEntity.ok().build(); return ResponseEntity.ok().build();
} }
} }

View File

@@ -9,43 +9,31 @@
*/ */
package org.eclipse.hawkbit.repository; package org.eclipse.hawkbit.repository;
import org.eclipse.hawkbit.im.authentication.SpPermission;
import org.eclipse.hawkbit.im.authentication.SpringEvalExpressions;
import org.eclipse.hawkbit.repository.model.DistributionSet; import org.eclipse.hawkbit.repository.model.DistributionSet;
import org.eclipse.hawkbit.repository.model.DistributionSetInvalidation; import org.eclipse.hawkbit.repository.model.DistributionSetInvalidation;
import org.eclipse.hawkbit.repository.model.DistributionSetInvalidationCount; import org.eclipse.hawkbit.repository.model.DistributionSetInvalidationCount;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
/** /**
* A DistributionSetInvalidationManagement service provides operations to * A DistributionSetInvalidationManagement service provides operations to invalidate {@link DistributionSet}s.
* invalidate {@link DistributionSet}s.
*/ */
public interface DistributionSetInvalidationManagement { public interface DistributionSetInvalidationManagement extends PermissionSupport {
@Override
default String permissionGroup() {
return SpPermission.DISTRIBUTION_SET;
}
/** /**
* Invalidates a given {@link DistributionSet}. The invalidation always * Invalidates a given {@link DistributionSet}. The invalidation always cancels all auto assignments referring this {@link DistributionSet}
* cancels all auto assignments referring this {@link DistributionSet} and * and can not be undone. Optionally, all rollouts and actions referring this {@link DistributionSet} can be canceled.
* can not be undone. Optionally, all rollouts and actions referring this
* {@link DistributionSet} can be canceled.
* <p> * <p>
* {@link PreAuthorize} missing intentionally as it relies on the permission set defined in the management api methods that it calls internally. * {@link PreAuthorize} missing intentionally as it relies on the permission set defined in the management api methods that it calls internally.
* *
* @param distributionSetInvalidation defines the {@link DistributionSet} and options what should be * @param distributionSetInvalidation defines the {@link DistributionSet} and options what should be canceled
* canceled
*/ */
@PreAuthorize(SpringEvalExpressions.HAS_UPDATE_REPOSITORY)
void invalidateDistributionSet(final DistributionSetInvalidation distributionSetInvalidation); void invalidateDistributionSet(final DistributionSetInvalidation distributionSetInvalidation);
}
/**
* Counts all entities for a list of {@link DistributionSet}s that will be
* canceled when invalidation is called for those {@link DistributionSet}s.
* <p>
* {@link PreAuthorize} missing intentionally as it relies on the permission set defined in the management api methods that it calls internally.
*
* @param distributionSetInvalidation defines the {@link DistributionSet} and options what should be
* canceled
* @return The {@link DistributionSetInvalidationCount} object that holds
* information about the count of affected rollouts,
* auto-assignments and actions
*/
DistributionSetInvalidationCount countEntitiesForInvalidation(
final DistributionSetInvalidation distributionSetInvalidation);
}

View File

@@ -159,5 +159,10 @@
<artifactId>hibernate-micrometer</artifactId> <artifactId>hibernate-micrometer</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency>
<groupId>io.github.classgraph</groupId>
<artifactId>classgraph</artifactId>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
</project> </project>

View File

@@ -359,8 +359,8 @@ public class JpaRepositoryConfiguration {
@ConditionalOnMissingBean @ConditionalOnMissingBean
RolloutHandler rolloutHandler(final TenantAware tenantAware, final RolloutManagement rolloutManagement, RolloutHandler rolloutHandler(final TenantAware tenantAware, final RolloutManagement rolloutManagement,
final RolloutExecutor rolloutExecutor, final LockRegistry lockRegistry, final RolloutExecutor rolloutExecutor, final LockRegistry lockRegistry,
final PlatformTransactionManager txManager, final ContextAware contextAware, final Optional<MeterRegistry> meterRegistry) { final PlatformTransactionManager txManager, final Optional<MeterRegistry> meterRegistry) {
return new JpaRolloutHandler(tenantAware, rolloutManagement, rolloutExecutor, lockRegistry, txManager, contextAware, meterRegistry); return new JpaRolloutHandler(tenantAware, rolloutManagement, rolloutExecutor, lockRegistry, txManager, meterRegistry);
} }
@Bean @Bean

View File

@@ -37,7 +37,6 @@ public class JpaRolloutHandler implements RolloutHandler {
private final RolloutExecutor rolloutExecutor; private final RolloutExecutor rolloutExecutor;
private final LockRegistry lockRegistry; private final LockRegistry lockRegistry;
private final PlatformTransactionManager txManager; private final PlatformTransactionManager txManager;
private final ContextAware contextAware;
private final Optional<MeterRegistry> meterRegistry; private final Optional<MeterRegistry> meterRegistry;
/** /**
@@ -51,14 +50,12 @@ public class JpaRolloutHandler implements RolloutHandler {
*/ */
public JpaRolloutHandler(final TenantAware tenantAware, final RolloutManagement rolloutManagement, public JpaRolloutHandler(final TenantAware tenantAware, final RolloutManagement rolloutManagement,
final RolloutExecutor rolloutExecutor, final LockRegistry lockRegistry, final RolloutExecutor rolloutExecutor, final LockRegistry lockRegistry,
final PlatformTransactionManager txManager, final PlatformTransactionManager txManager, final Optional<MeterRegistry> meterRegistry) {
final ContextAware contextAware, final Optional<MeterRegistry> meterRegistry) {
this.tenantAware = tenantAware; this.tenantAware = tenantAware;
this.rolloutManagement = rolloutManagement; this.rolloutManagement = rolloutManagement;
this.rolloutExecutor = rolloutExecutor; this.rolloutExecutor = rolloutExecutor;
this.lockRegistry = lockRegistry; this.lockRegistry = lockRegistry;
this.txManager = txManager; this.txManager = txManager;
this.contextAware = contextAware;
this.meterRegistry = meterRegistry; this.meterRegistry = meterRegistry;
} }

View File

@@ -69,7 +69,7 @@ import org.springframework.validation.annotation.Validated;
*/ */
// Spring AOP doesn't support bridge methods and the AspectJ advices as ExceptionMappingAspectHandler could not handle the // Spring AOP doesn't support bridge methods and the AspectJ advices as ExceptionMappingAspectHandler could not handle the
// thrown exception (e.g. to convert AuthorizationDeniedException to InsufficientPermissionException). // thrown exception (e.g. to convert AuthorizationDeniedException to InsufficientPermissionException).
// That's why we explictly handle the insufficient permission exception with this @HandleAuthorizationDenied annotation. // That's why we explicitly handle the insufficient permission exception with this @HandleAuthorizationDenied annotation.
@HandleAuthorizationDenied(handlerClass = JpaRepositoryConfiguration.ManagementExceptionThrowingMethodAuthorizationDeniedHandler.class) @HandleAuthorizationDenied(handlerClass = JpaRepositoryConfiguration.ManagementExceptionThrowingMethodAuthorizationDeniedHandler.class)
@Transactional(readOnly = true) @Transactional(readOnly = true)
@Validated @Validated

View File

@@ -49,7 +49,6 @@ public class JpaDistributionSetInvalidationManagement implements DistributionSet
private final RolloutManagement rolloutManagement; private final RolloutManagement rolloutManagement;
private final DeploymentManagement deploymentManagement; private final DeploymentManagement deploymentManagement;
private final TargetFilterQueryManagement<? extends TargetFilterQuery> targetFilterQueryManagement; private final TargetFilterQueryManagement<? extends TargetFilterQuery> targetFilterQueryManagement;
private final ActionRepository actionRepository;
private final PlatformTransactionManager txManager; private final PlatformTransactionManager txManager;
private final RepositoryProperties repositoryProperties; private final RepositoryProperties repositoryProperties;
private final TenantAware tenantAware; private final TenantAware tenantAware;
@@ -60,7 +59,7 @@ public class JpaDistributionSetInvalidationManagement implements DistributionSet
protected JpaDistributionSetInvalidationManagement( protected JpaDistributionSetInvalidationManagement(
final DistributionSetManagement<? extends DistributionSet> distributionSetManagement, final DistributionSetManagement<? extends DistributionSet> distributionSetManagement,
final RolloutManagement rolloutManagement, final DeploymentManagement deploymentManagement, final RolloutManagement rolloutManagement, final DeploymentManagement deploymentManagement,
final TargetFilterQueryManagement<? extends TargetFilterQuery> targetFilterQueryManagement, final ActionRepository actionRepository, final TargetFilterQueryManagement<? extends TargetFilterQuery> targetFilterQueryManagement,
final PlatformTransactionManager txManager, final RepositoryProperties repositoryProperties, final PlatformTransactionManager txManager, final RepositoryProperties repositoryProperties,
final TenantAware tenantAware, final LockRegistry lockRegistry, final TenantAware tenantAware, final LockRegistry lockRegistry,
final SystemSecurityContext systemSecurityContext) { final SystemSecurityContext systemSecurityContext) {
@@ -68,7 +67,6 @@ public class JpaDistributionSetInvalidationManagement implements DistributionSet
this.rolloutManagement = rolloutManagement; this.rolloutManagement = rolloutManagement;
this.deploymentManagement = deploymentManagement; this.deploymentManagement = deploymentManagement;
this.targetFilterQueryManagement = targetFilterQueryManagement; this.targetFilterQueryManagement = targetFilterQueryManagement;
this.actionRepository = actionRepository;
this.txManager = txManager; this.txManager = txManager;
this.repositoryProperties = repositoryProperties; this.repositoryProperties = repositoryProperties;
this.tenantAware = tenantAware; this.tenantAware = tenantAware;
@@ -103,26 +101,11 @@ public class JpaDistributionSetInvalidationManagement implements DistributionSet
} }
} }
@Override
public DistributionSetInvalidationCount countEntitiesForInvalidation(
final DistributionSetInvalidation distributionSetInvalidation) {
return systemSecurityContext.runAsSystem(() -> {
final Collection<Long> setIds = distributionSetInvalidation.getDistributionSetIds();
final long rolloutsCount = shouldRolloutsBeCanceled(distributionSetInvalidation.getActionCancellationType()) ? countRolloutsForInvalidation(setIds) : 0;
final long autoAssignmentsCount = countAutoAssignmentsForInvalidation(setIds);
final long actionsCount = countActionsForInvalidation(setIds,
distributionSetInvalidation.getActionCancellationType());
return new DistributionSetInvalidationCount(rolloutsCount, autoAssignmentsCount, actionsCount);
});
}
private static boolean shouldRolloutsBeCanceled(final ActionCancellationType cancelationType) { private static boolean shouldRolloutsBeCanceled(final ActionCancellationType cancelationType) {
return cancelationType != ActionCancellationType.NONE; return cancelationType != ActionCancellationType.NONE;
} }
private void invalidateDistributionSetsInTransaction(final DistributionSetInvalidation distributionSetInvalidation, private void invalidateDistributionSetsInTransaction(final DistributionSetInvalidation distributionSetInvalidation, final String tenant) {
final String tenant) {
DeploymentHelper.runInNewTransaction(txManager, tenant + "-invalidateDS", status -> { DeploymentHelper.runInNewTransaction(txManager, tenant + "-invalidateDS", status -> {
distributionSetInvalidation.getDistributionSetIds().forEach(setId -> invalidateDistributionSet(setId, distributionSetInvalidation.getDistributionSetIds().forEach(setId -> invalidateDistributionSet(setId,
distributionSetInvalidation.getActionCancellationType())); distributionSetInvalidation.getActionCancellationType()));
@@ -158,33 +141,4 @@ public class JpaDistributionSetInvalidationManagement implements DistributionSet
return null; return null;
}); });
} }
private long countRolloutsForInvalidation(final Collection<Long> setIds) {
return setIds.stream().mapToLong(rolloutManagement::countByDistributionSetIdAndRolloutIsStoppable).sum();
}
private long countAutoAssignmentsForInvalidation(final Collection<Long> setIds) {
return setIds.stream().mapToLong(targetFilterQueryManagement::countByAutoAssignDistributionSetId).sum();
}
private long countActionsForInvalidation(final Collection<Long> setIds, final ActionCancellationType cancelationType) {
long affectedActionsByDSInvalidation = 0;
if (cancelationType == ActionCancellationType.FORCE) {
affectedActionsByDSInvalidation = countActionsForForcedInvalidation(setIds);
} else if (cancelationType == ActionCancellationType.SOFT) {
affectedActionsByDSInvalidation = countActionsForSoftInvalidation(setIds);
}
return affectedActionsByDSInvalidation;
}
private long countActionsForForcedInvalidation(final Collection<Long> setIds) {
return setIds.stream().mapToLong(actionRepository::countByDistributionSetIdAndActiveIsTrue).sum();
}
private long countActionsForSoftInvalidation(final Collection<Long> setIds) {
return setIds.stream()
.mapToLong(distributionSet -> actionRepository
.countByDistributionSetIdAndActiveIsTrueAndStatusIsNot(distributionSet, Status.CANCELING))
.sum();
}
} }

View File

@@ -12,9 +12,11 @@ package org.eclipse.hawkbit.repository.jpa.management;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import lombok.Data;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.eclipse.hawkbit.repository.TargetFilterQueryManagement; import org.eclipse.hawkbit.repository.TargetFilterQueryManagement;
import org.eclipse.hawkbit.repository.exception.IncompleteDistributionSetException; import org.eclipse.hawkbit.repository.exception.IncompleteDistributionSetException;
@@ -55,8 +57,7 @@ class DistributionSetInvalidationManagementTest extends AbstractJpaIntegrationTe
final DistributionSetInvalidation distributionSetInvalidation = new DistributionSetInvalidation( final DistributionSetInvalidation distributionSetInvalidation = new DistributionSetInvalidation(
Collections.singletonList(invalidationTestData.getDistributionSet().getId()), ActionCancellationType.NONE); Collections.singletonList(invalidationTestData.getDistributionSet().getId()), ActionCancellationType.NONE);
final DistributionSetInvalidationCount distributionSetInvalidationCount = distributionSetInvalidationManagement final DistributionSetInvalidationCount distributionSetInvalidationCount = countEntitiesForInvalidation(distributionSetInvalidation);
.countEntitiesForInvalidation(distributionSetInvalidation);
assertDistributionSetInvalidationCount(distributionSetInvalidationCount, 1, 0, 0); assertDistributionSetInvalidationCount(distributionSetInvalidationCount, 1, 0, 0);
distributionSetInvalidationManagement.invalidateDistributionSet(distributionSetInvalidation); distributionSetInvalidationManagement.invalidateDistributionSet(distributionSetInvalidation);
@@ -85,8 +86,7 @@ class DistributionSetInvalidationManagementTest extends AbstractJpaIntegrationTe
final DistributionSetInvalidation distributionSetInvalidation = new DistributionSetInvalidation( final DistributionSetInvalidation distributionSetInvalidation = new DistributionSetInvalidation(
Collections.singletonList(invalidationTestData.getDistributionSet().getId()), ActionCancellationType.NONE); Collections.singletonList(invalidationTestData.getDistributionSet().getId()), ActionCancellationType.NONE);
final DistributionSetInvalidationCount distributionSetInvalidationCount = distributionSetInvalidationManagement final DistributionSetInvalidationCount distributionSetInvalidationCount = countEntitiesForInvalidation(distributionSetInvalidation);
.countEntitiesForInvalidation(distributionSetInvalidation);
assertDistributionSetInvalidationCount(distributionSetInvalidationCount, 1, 0, 0); assertDistributionSetInvalidationCount(distributionSetInvalidationCount, 1, 0, 0);
distributionSetInvalidationManagement.invalidateDistributionSet(distributionSetInvalidation); distributionSetInvalidationManagement.invalidateDistributionSet(distributionSetInvalidation);
@@ -118,8 +118,7 @@ class DistributionSetInvalidationManagementTest extends AbstractJpaIntegrationTe
final DistributionSetInvalidation distributionSetInvalidation = new DistributionSetInvalidation( final DistributionSetInvalidation distributionSetInvalidation = new DistributionSetInvalidation(
Collections.singletonList(invalidationTestData.getDistributionSet().getId()), ActionCancellationType.FORCE); Collections.singletonList(invalidationTestData.getDistributionSet().getId()), ActionCancellationType.FORCE);
final DistributionSetInvalidationCount distributionSetInvalidationCount = distributionSetInvalidationManagement final DistributionSetInvalidationCount distributionSetInvalidationCount = countEntitiesForInvalidation(distributionSetInvalidation);
.countEntitiesForInvalidation(distributionSetInvalidation);
assertDistributionSetInvalidationCount(distributionSetInvalidationCount, 1, 5, 1); assertDistributionSetInvalidationCount(distributionSetInvalidationCount, 1, 5, 1);
distributionSetInvalidationManagement.invalidateDistributionSet(distributionSetInvalidation); distributionSetInvalidationManagement.invalidateDistributionSet(distributionSetInvalidation);
@@ -142,8 +141,7 @@ class DistributionSetInvalidationManagementTest extends AbstractJpaIntegrationTe
final DistributionSetInvalidation distributionSetInvalidation = new DistributionSetInvalidation( final DistributionSetInvalidation distributionSetInvalidation = new DistributionSetInvalidation(
Collections.singletonList(invalidationTestData.getDistributionSet().getId()), ActionCancellationType.SOFT); Collections.singletonList(invalidationTestData.getDistributionSet().getId()), ActionCancellationType.SOFT);
final DistributionSetInvalidationCount distributionSetInvalidationCount = distributionSetInvalidationManagement final DistributionSetInvalidationCount distributionSetInvalidationCount = countEntitiesForInvalidation(distributionSetInvalidation);
.countEntitiesForInvalidation(distributionSetInvalidation);
assertDistributionSetInvalidationCount(distributionSetInvalidationCount, 1, 5, 1); assertDistributionSetInvalidationCount(distributionSetInvalidationCount, 1, 5, 1);
distributionSetInvalidationManagement.invalidateDistributionSet(distributionSetInvalidation); distributionSetInvalidationManagement.invalidateDistributionSet(distributionSetInvalidation);
@@ -278,6 +276,48 @@ class DistributionSetInvalidationManagementTest extends AbstractJpaIntegrationTe
return actionRepository.findAll(ActionSpecifications.byTargetControllerId(target.getControllerId())); return actionRepository.findAll(ActionSpecifications.byTargetControllerId(target.getControllerId()));
} }
private DistributionSetInvalidationCount countEntitiesForInvalidation(
final DistributionSetInvalidation distributionSetInvalidation) {
return systemSecurityContext.runAsSystem(() -> {
final Collection<Long> setIds = distributionSetInvalidation.getDistributionSetIds();
final long rolloutsCount = distributionSetInvalidation.getActionCancellationType() != ActionCancellationType.NONE ? countRolloutsForInvalidation(setIds) : 0;
final long autoAssignmentsCount = countAutoAssignmentsForInvalidation(setIds);
final long actionsCount = countActionsForInvalidation(setIds, distributionSetInvalidation.getActionCancellationType());
return new DistributionSetInvalidationCount(rolloutsCount, autoAssignmentsCount, actionsCount);
});
}
private long countRolloutsForInvalidation(final Collection<Long> setIds) {
return setIds.stream().mapToLong(rolloutManagement::countByDistributionSetIdAndRolloutIsStoppable).sum();
}
private long countAutoAssignmentsForInvalidation(final Collection<Long> setIds) {
return setIds.stream().mapToLong(targetFilterQueryManagement::countByAutoAssignDistributionSetId).sum();
}
private long countActionsForInvalidation(final Collection<Long> setIds, final ActionCancellationType cancelationType) {
long affectedActionsByDSInvalidation = 0;
if (cancelationType == ActionCancellationType.FORCE) {
affectedActionsByDSInvalidation = countActionsForForcedInvalidation(setIds);
} else if (cancelationType == ActionCancellationType.SOFT) {
affectedActionsByDSInvalidation = countActionsForSoftInvalidation(setIds);
}
return affectedActionsByDSInvalidation;
}
private long countActionsForForcedInvalidation(final Collection<Long> setIds) {
return setIds.stream().mapToLong(actionRepository::countByDistributionSetIdAndActiveIsTrue).sum();
}
private long countActionsForSoftInvalidation(final Collection<Long> setIds) {
return setIds.stream()
.mapToLong(distributionSet -> actionRepository
.countByDistributionSetIdAndActiveIsTrueAndStatusIsNot(distributionSet, Status.CANCELING))
.sum();
}
@Data
private static class InvalidationTestData { private static class InvalidationTestData {
private final DistributionSet distributionSet; private final DistributionSet distributionSet;
@@ -285,7 +325,8 @@ class DistributionSetInvalidationManagementTest extends AbstractJpaIntegrationTe
private final TargetFilterQuery targetFilterQuery; private final TargetFilterQuery targetFilterQuery;
private final Rollout rollout; private final Rollout rollout;
public InvalidationTestData(final DistributionSet distributionSet, final List<Target> targets, public InvalidationTestData(
final DistributionSet distributionSet, final List<Target> targets,
final TargetFilterQuery targetFilterQuery, final Rollout rollout) { final TargetFilterQuery targetFilterQuery, final Rollout rollout) {
super(); super();
this.distributionSet = distributionSet; this.distributionSet = distributionSet;
@@ -293,21 +334,5 @@ class DistributionSetInvalidationManagementTest extends AbstractJpaIntegrationTe
this.targetFilterQuery = targetFilterQuery; this.targetFilterQuery = targetFilterQuery;
this.rollout = rollout; this.rollout = rollout;
} }
public DistributionSet getDistributionSet() {
return distributionSet;
}
public List<Target> getTargets() {
return targets;
}
public TargetFilterQuery getTargetFilterQuery() {
return targetFilterQuery;
}
public Rollout getRollout() {
return rollout;
}
} }
} }

View File

@@ -0,0 +1,458 @@
/**
* Copyright (c) 2025 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.repository.jpa.management;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.fail;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.io.Serializable;
import java.lang.reflect.Array;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.net.URI;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.function.Consumer;
import java.util.stream.Stream;
import io.github.classgraph.ClassGraph;
import io.github.classgraph.ClassInfo;
import io.github.classgraph.ScanResult;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.hawkbit.repository.PermissionSupport;
import org.eclipse.hawkbit.repository.TenantStatsManagement;
import org.eclipse.hawkbit.repository.exception.InsufficientPermissionException;
import org.eclipse.hawkbit.repository.jpa.AbstractJpaIntegrationTest;
import org.eclipse.hawkbit.repository.test.util.AbstractIntegrationTest;
import org.eclipse.hawkbit.repository.test.util.SecurityContextSwitch;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Pageable;
import org.springframework.expression.spel.SpelNode;
import org.springframework.expression.spel.ast.MethodReference;
import org.springframework.expression.spel.ast.OpAnd;
import org.springframework.expression.spel.ast.OpOr;
import org.springframework.expression.spel.ast.StringLiteral;
import org.springframework.expression.spel.ast.VariableReference;
import org.springframework.expression.spel.standard.SpelExpression;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.authorization.AuthorizationDeniedException;
import org.springframework.test.context.TestPropertySource;
import org.springframework.util.ClassUtils;
import org.springframework.util.ObjectUtils;
@Slf4j
@TestPropertySource(properties = { "logging.level.org.eclipse.hawkbit.repository.test.util=off" })
class ManagementSecurityTest extends AbstractJpaIntegrationTest {
private static final SpelExpressionParser SPEL_EXPRESSION_PARSER = new SpelExpressionParser();
@Autowired
protected TenantStatsManagement tenantStatsManagement;
@Override
@BeforeEach
public void beforeAll() throws Exception {
// override - shall not do anything
}
@ParameterizedTest
@MethodSource("testMethods")
void testMethod(final Class<?> managementInterface, final Method managementInterfaceMethod) {
final Object managementObject = TenantStatsManagement.class == managementInterface
? tenantStatsManagement // it's not a field of AbstractIntegrationTest, so we need to use the autowired instance
: Stream
.of(AbstractIntegrationTest.class.getDeclaredFields())
.filter(field -> managementInterface.isAssignableFrom(field.getType()))
.findFirst()
.map(field -> {
field.setAccessible(true);
try {
return field.get(this);
} catch (final IllegalAccessException e) {
throw new AssertionError("Could not access field " + field.getName(), e);
}
})
.orElseThrow(() -> new AssertionError("No management implementation found for " + managementInterface));
final Class<?> managedClass = ClassUtils.getUserClass(managementObject); // managed class is a proxy
final Method implementationMethod = findImplementationMethod(managedClass, managementInterfaceMethod);
if (implementationMethod == null) {
throw new AssertionError("No management implementation found for " + managementInterfaceMethod + " in " + managedClass.getName());
}
final String permissionGroup = managementObject instanceof PermissionSupport permissionSupport
? permissionSupport.permissionGroup()
: null;
Set<String> preAuthorizedPermissions = collectPreAuthorizedPermissions(implementationMethod, permissionGroup);
if (ObjectUtils.isEmpty(preAuthorizedPermissions)) {
preAuthorizedPermissions = collectPreAuthorizedPermissions(managementInterfaceMethod, permissionGroup);
}
if (ObjectUtils.isEmpty(preAuthorizedPermissions)) {
fail("No PreAuthorize annotation found for " + managementInterface.getSimpleName() + " -> " + implementationMethod);
} else {
assertPermissionsCheck(managementInterfaceMethod, managementObject, preAuthorizedPermissions.toArray(new String[0]));
}
}
private static Stream<Arguments> testMethods() {
final String packageName = "org.eclipse.hawkbit.repository";
try (final ScanResult scanResult = new ClassGraph().acceptPackages(packageName).scan()) {
return scanResult.getAllClasses()
.stream()
// scan scans subpackages as well, so we need to filter out the classes that are not in the package (e.g. JpaActionManagement)
.filter(classInPackage -> classInPackage.getPackageName().equals(packageName))
.filter(classInPackage -> classInPackage.getSimpleName().endsWith("Management"))
// RepositoryManagement is not a management interface but a super of such interfaces
.filter(classInPackage -> !classInPackage.getSimpleName().equals("RepositoryManagement"))
// QuotaManagement and its implementation PropertiesQuotaManagement is not protected using @PreAuthorize
// it is not an exposed db service but internally used
.filter(classInPackage -> !classInPackage.getSimpleName().equals("QuotaManagement") &&
!classInPackage.getSimpleName().equals("PropertiesQuotaManagement"))
.map(ClassInfo::loadClass)
.flatMap(clazz -> collectMethods(clazz, new ArrayList<>()).stream()
// permissionGroup is an internal method and should not be protected by @PreAuthorize
.filter(method -> !"permissionGroup".equals(method.getName()) ||
method.getParameterCount() != 0 ||
method.getReturnType() != String.class)
// jacoco adds some methods with bytecode instrumentation
.filter(method -> !"$jacocoInit".equals(method.getName()))
.map(method -> Arguments.of(clazz, method)))
// consumes the stream because scan result couldn't be used after being closed
.toList()
.stream();
}
}
private static List<Method> collectMethods(final Class<?> clazz, final List<Method> methods) {
methods.addAll(Arrays.asList(clazz.getDeclaredMethods()));
for (final Class<?> interfaceClass : clazz.getInterfaces()) {
collectMethods(interfaceClass, methods);
}
if (clazz.getSuperclass() != null) {
collectMethods(clazz.getSuperclass(), methods);
}
return methods;
}
private static Method findImplementationMethod(final Class<?> managementClass, final Method managementInterfaceMethod) {
final Method classMethod = findClassImplementationMethod(managementClass, managementInterfaceMethod);
if (classMethod == null) {
return findInterfaceDefaultMethod(managementClass, managementInterfaceMethod);
} else {
return classMethod;
}
}
private static Method findClassImplementationMethod(final Class<?> managementClass, final Method managementInterfaceMethod) {
return Stream.of(managementClass.getDeclaredMethods())
.filter(m -> match(m, managementInterfaceMethod))
.findFirst()
.orElseGet(() -> {
final Class<?> superClass = managementClass.getSuperclass();
if (superClass == null) {
return null;
} else {
return findImplementationMethod(superClass, managementInterfaceMethod);
}
});
}
private static Method findInterfaceDefaultMethod(final Class<?> managementClassOrInterface, final Method managementInterfaceMethod) {
if (!managementInterfaceMethod.getDeclaringClass().isAssignableFrom(managementClassOrInterface)) {
return null;
}
Method interfaceMethod = null;
for (final Class<?> superInterface : managementClassOrInterface.getInterfaces()) {
final Method method = Stream.of(superInterface.getDeclaredMethods())
.filter(Method::isDefault)
.filter(m -> match(m, managementInterfaceMethod))
.findFirst()
.orElseGet(() -> findInterfaceDefaultMethod(superInterface, managementInterfaceMethod));
if (method != null) { // found
if (interfaceMethod != null) {
// should not happen, but check anyway
throw new IllegalStateException(
"Multiple default methods found for " + managementInterfaceMethod + " in interfaces: " + interfaceMethod + " and " + method);
}
interfaceMethod = method;
}
}
return interfaceMethod;
}
private static boolean match(final Method method, final Method managementInterfaceMethod) {
return method.getName().equals(managementInterfaceMethod.getName()) &&
// TODO - check for generics
Arrays.equals(method.getParameterTypes(), managementInterfaceMethod.getParameterTypes());
}
private Set<String> collectPreAuthorizedPermissions(final Method method, final String permissionGroup) {
if (method.isAnnotationPresent(PreAuthorize.class)) {
final PreAuthorize preAuthorize = method.getAnnotation(PreAuthorize.class);
final SpelExpression expr = (SpelExpression) SPEL_EXPRESSION_PARSER.parseExpression(preAuthorize.value());
final Set<String> expressionPermissions = new HashSet<>();
addSufficientPermissions(expr.getAST(), expressionPermissions, permissionGroup);
if (expressionPermissions.isEmpty()) {
throw new IllegalStateException("No permissions found in expression: " + preAuthorize.value());
}
return expressionPermissions;
} else {
return null;
}
}
private void addSufficientPermissions(final SpelNode spelNode, final Set<String> preAuthorizedPermissions, final String permissionGroup) {
if (spelNode instanceof OpOr) {
addSufficientPermissions(spelNode.getChild(0), preAuthorizedPermissions, permissionGroup);
} else if (spelNode instanceof OpAnd) {
for (int i = 0; i < spelNode.getChildCount(); i++) {
addSufficientPermissions(spelNode.getChild(i), preAuthorizedPermissions, permissionGroup);
}
} else if (spelNode instanceof MethodReference methodReference) {
final String method = methodReference.getName();
switch (method) {
case "hasAuthority" -> {
for (int i = 0; i < spelNode.getChildCount(); i++) {
addSufficientPermissions(spelNode.getChild(i), preAuthorizedPermissions, permissionGroup);
}
}
case "hasAnyRole" -> {
final SpelNode child = spelNode.getChild(0);
if (child instanceof StringLiteral literal) {
final String permission = (String) literal.getLiteralValue().getValue();
preAuthorizedPermissions.add(permission.toUpperCase().startsWith("ROLE_") ? permission : "ROLE_" + permission);
} else {
addSufficientPermissions(child, preAuthorizedPermissions, permissionGroup);
}
}
case "hasPermission" -> {
assertThat(spelNode.getChildCount()).isEqualTo(2);
assertThat(spelNode.getChild(0) instanceof VariableReference varRef && varRef.toStringAST().equals("#root")).isTrue();
assertThat(spelNode.getChild(1)).isInstanceOf(StringLiteral.class);
final StringLiteral literal = (StringLiteral) spelNode.getChild(1);
preAuthorizedPermissions.add(literal.getLiteralValue().getValue() + "_" + permissionGroup);
}
default -> throw new IllegalStateException("Unexpected MethodReference: " + method);
}
} else if (spelNode instanceof StringLiteral literal) {
preAuthorizedPermissions.add((String) literal.getLiteralValue().getValue());
} else {
throw new IllegalStateException("Unexpected SpelNode: " + spelNode + " of type " + spelNode.getClass());
}
}
@SneakyThrows
@SuppressWarnings("rawtypes")
private Object instance(final Class<?> clazz) {
if (clazz.isArray()) {
return Array.newInstance(clazz.getComponentType(), 0);
}
if (Collection.class.isAssignableFrom(clazz)) {
if (clazz == List.class || clazz == Collection.class) {
return new ArrayList<>();
} else if (clazz == Set.class) {
return new HashSet<>();
} else {
throw new IllegalStateException("No instance for collection interface " + clazz);
}
}
if (clazz == Map.class) {
return new HashMap<>();
}
if (clazz.isInterface()) {
if (clazz == Pageable.class) {
return Pageable.ofSize(10);
} else if (clazz == Serializable.class) {
return "";
} else if (clazz == Consumer.class) {
return (Consumer<String>) s -> {};
} else if (clazz.getPackageName().startsWith("org.eclipse.hawkbit.repository")) {
if (clazz.getSimpleName().endsWith("Management")) {
return Stream
.of(AbstractIntegrationTest.class.getDeclaredFields())
.filter(field -> clazz.isAssignableFrom(field.getType()))
.findFirst()
.map(field -> {
field.setAccessible(true);
try {
return field.get(this);
} catch (final IllegalAccessException e) {
throw new AssertionError("Could not access field " + field.getName(), e);
}
})
.orElseThrow(() -> new IllegalStateException("No management implementation found for " + clazz));
}
try (final ScanResult scanResult = new ClassGraph().acceptPackages("org.eclipse.hawkbit.repository").scan()) {
return scanResult.getClassesImplementing(clazz)
.stream()
.filter(impl -> !impl.isAbstract())
.findFirst()
.map(impl -> instance(impl.loadClass()))
.orElseThrow(() -> new IllegalStateException("No instance for interface " + clazz));
}
} else {
throw new IllegalStateException("No instance for interface " + clazz);
}
}
if (clazz.isEnum()) {
return clazz.getEnumConstants()[0];
}
if (clazz == boolean.class || clazz == Boolean.class) {
return false;
} else if (clazz == int.class || clazz == Integer.class) {
return 1;
} else if (clazz == long.class || clazz == Long.class) {
return 1L;
} else if (clazz == float.class || clazz == Float.class) {
return 1.0f;
} else if (clazz == double.class || clazz == Double.class) {
return 1.0;
} else if (clazz == short.class || clazz == Short.class) {
return (short) 1;
} else if (clazz == byte.class || clazz == Byte.class) {
return (byte) 1;
} else if (clazz == char.class || clazz == Character.class) {
return 'a';
} else if (clazz == String.class) {
return "id==0";
} else if (clazz == InputStream.class) {
return new ByteArrayInputStream(new byte[1]);
} else if (clazz == URI.class) {
return new URI("http://localhost");
} else if (clazz == Class.class) {
return String.class;
} else {
try {
final Constructor[] constructors = clazz.getDeclaredConstructors();
if (ObjectUtils.isEmpty(constructors)) {
throw new IllegalStateException("No public constructor found for " + clazz);
}
// prefer empty constructor
for (final Constructor constructor : constructors) {
if (constructor.getParameterCount() == 0) {
constructor.setAccessible(true);
return constructor.newInstance();
}
}
constructors[0].setAccessible(true);
return constructors[0].newInstance(Stream.of(constructors[0].getParameterTypes())
.map(this::instance)
.toArray());
} catch (final InstantiationException e) {
// try builder pattern
try {
final Object builder = clazz.getDeclaredMethod("builder").invoke(null);
final Method build = builder.getClass().getDeclaredMethod("build");
build.setAccessible(true);
return build.invoke(builder);
} catch (final NoSuchMethodException | IllegalAccessException | InvocationTargetException e1) {
log.debug("{} is not a builder. Throws could not instantiate", clazz.getName());
}
log.error("Could not instantiate {}", clazz.getName(), e);
throw e;
}
}
}
private static final Set<String> EXPECTED_EXCEPTIONS_TYPES = new HashSet<>();
@AfterAll
static void afterAll() {
final List<String> exceptions = new ArrayList<>(EXPECTED_EXCEPTIONS_TYPES);
Collections.sort(exceptions);
log.info("Expected exceptions occurred during tests:\n\t{}", String.join("\n\t", exceptions));
}
@SneakyThrows
protected void assertPermissionsCheck(final Method managementInterfaceMethod, final Object managementObject, final String... permissions) {
final Callable<?> callable = () -> {
try {
final Object[] params = new Object[managementInterfaceMethod.getParameterCount()];
for (int i = 0; i < params.length; i++) {
params[i] = instance(managementInterfaceMethod.getParameterTypes()[i]);
}
return managementInterfaceMethod.invoke(managementObject, params);
} catch (final InvocationTargetException e) {
if (e.getCause() instanceof RuntimeException re) {
throw re;
} else {
throw new AssertionError(e.getCause());
}
}
};
// check if the user has the correct permissions
SecurityContextSwitch.runAs(SecurityContextSwitch.withUser("user_with_permissions", permissions), () -> {
try {
callable.call();
} catch (final Throwable th) {
if (th instanceof InsufficientPermissionException || th instanceof AuthorizationDeniedException) {
throw new AssertionError(
"Expected no InsufficientPermissionException or AuthorizationDeniedException to be thrown, but got: " + th +
" (permissions: " + Arrays.toString(permissions) + ")", th);
} else {
Stream.of(th.getStackTrace())
.filter(stackTraceElement -> {
// if the method seem to exist in the stack trace
try {
final Class<?> clazz = Class.forName(stackTraceElement.getClassName());
return clazz.isAssignableFrom(managementObject.getClass()) && // in class or implementation in hierarchy
stackTraceElement.getMethodName().equals(managementInterfaceMethod.getName()); //
} catch (final ClassNotFoundException e) {
return false;
}
})
.findAny()
.orElseThrow(() -> new AssertionError(
"Unexpected Exception is thrown (permissions: " + Arrays.toString(permissions) + ")", th));
EXPECTED_EXCEPTIONS_TYPES.add(th.getClass().getName());
log.debug("Expected catch: {}", th.getMessage());
}
}
});
// check if the user has not the correct permissions
final String[] permissionsWithoutOne = new String[permissions.length - 1];
System.arraycopy(permissions, 0, permissionsWithoutOne, 0, permissionsWithoutOne.length);
SecurityContextSwitch.runAs(SecurityContextSwitch.withUser("user_without_permissions", permissionsWithoutOne), () -> {
try {
callable.call();
throw new AssertionError(
"Expected Exception InsufficientPermissionException to be thrown, but request passed with no exception" +
" (permissions: " + Arrays.toString(permissionsWithoutOne) + ", needed: " + Arrays.asList(permissions) + ")");
} catch (final Exception ex) {
// default interface methods as TargetManagement.getWithAutoConfigurationStatus are not handled to
// throw InsufficientPermissionException
assertThat(ex).isInstanceOfAny(InsufficientPermissionException.class, AuthorizationDeniedException.class);
}
});
}
}