From b702ea41d155c15ae8f4e51a1b9adc2fe3a0b02b Mon Sep 17 00:00:00 2001 From: Avgustin Marinov Date: Tue, 23 Sep 2025 13:31:17 +0300 Subject: [PATCH] Implement Action Access Control (#2687) Signed-off-by: Avgustin Marinov --- .../DefaultAccessControllerConfiguration.java | 45 ++++++++- .../jpa/acm/ActionAccessControllerTest.java | 95 +++++++++++++++++++ 2 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/acm/ActionAccessControllerTest.java diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/acm/DefaultAccessControllerConfiguration.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/acm/DefaultAccessControllerConfiguration.java index 5f636bb45..06a5f5f5e 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/acm/DefaultAccessControllerConfiguration.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/acm/DefaultAccessControllerConfiguration.java @@ -9,7 +9,14 @@ */ package org.eclipse.hawkbit.repository.jpa.acm; +import java.lang.reflect.Proxy; import java.util.List; +import java.util.Optional; + +import jakarta.persistence.criteria.From; +import jakarta.persistence.criteria.Join; +import jakarta.persistence.criteria.Root; +import jakarta.persistence.metamodel.EntityType; import lombok.Getter; import org.eclipse.hawkbit.im.authentication.SpPermission; @@ -21,7 +28,9 @@ import org.eclipse.hawkbit.repository.SoftwareModuleTypeFields; import org.eclipse.hawkbit.repository.TagFields; import org.eclipse.hawkbit.repository.TargetFields; import org.eclipse.hawkbit.repository.TargetTypeFields; +import org.eclipse.hawkbit.repository.exception.InsufficientPermissionException; import org.eclipse.hawkbit.repository.jpa.model.JpaAction; +import org.eclipse.hawkbit.repository.jpa.model.JpaAction_; import org.eclipse.hawkbit.repository.jpa.model.JpaDistributionSet; import org.eclipse.hawkbit.repository.jpa.model.JpaDistributionSetType; import org.eclipse.hawkbit.repository.jpa.model.JpaSoftwareModule; @@ -31,6 +40,7 @@ import org.eclipse.hawkbit.repository.jpa.model.JpaTargetType; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.domain.Specification; @Configuration @ConditionalOnProperty(name = "hawkbit.acm.access-controller.enabled", havingValue = "true", matchIfMissing = true) @@ -42,11 +52,38 @@ public class DefaultAccessControllerConfiguration { return new DefaultAccessController<>(TargetFields.class, SpPermission.TARGET); } - // after adding support for internal action field search support it add matchIfMissing = true @Bean - @ConditionalOnProperty(name = "hawkbit.acm.access-controller.action.enabled", havingValue = "true", matchIfMissing = false) - AccessController actionAccessController() { - return new DefaultAccessController<>(ActionFieldsInternal.class, SpPermission.TARGET); + @ConditionalOnProperty(name = "hawkbit.acm.access-controller.action.enabled", havingValue = "true", matchIfMissing = true) + AccessController actionAccessController(final AccessController targetAccessController) { + return new AccessController<>() { + + @Override + @SuppressWarnings("unchecked") + public Optional> getAccessRules(final Operation operation) { + return targetAccessController.getAccessRules(operation).map(targetSpec -> (actionRoot, query, cb) -> { + final Join targetJoin = actionRoot.join(JpaAction_.target); + final EntityType targetModel = query.from(JpaTarget.class).getModel(); + final Root targetRoot = (Root) Proxy.newProxyInstance( + actionRoot.getClass().getClassLoader(), + new Class[] { Root.class }, + (proxy, method, args) -> { + if (method.getName().equals("getModel") && method.getParameterCount() == 0) { + return targetModel; + } else if (method.getDeclaringClass().isAssignableFrom(From.class)) { + return method.invoke(targetJoin, args); + } else { + return method.invoke(this, args); + } + }); + return targetSpec.toPredicate(targetRoot, query, cb); + }); + } + + @Override + public void assertOperationAllowed(final Operation operation, final JpaAction entity) throws InsufficientPermissionException { + targetAccessController.assertOperationAllowed(operation, entity.getTarget()); + } + }; } @Bean diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/acm/ActionAccessControllerTest.java b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/acm/ActionAccessControllerTest.java new file mode 100644 index 000000000..6341096c5 --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/acm/ActionAccessControllerTest.java @@ -0,0 +1,95 @@ +/** + * 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.acm; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.eclipse.hawkbit.im.authentication.SpPermission.READ_TARGET; +import static org.eclipse.hawkbit.repository.test.util.SecurityContextSwitch.runAs; +import static org.eclipse.hawkbit.repository.test.util.SecurityContextSwitch.withUser; + +import java.util.Set; + +import org.eclipse.hawkbit.repository.jpa.AbstractJpaIntegrationTest; +import org.eclipse.hawkbit.repository.jpa.model.JpaAction; +import org.eclipse.hawkbit.repository.model.Action; +import org.eclipse.hawkbit.repository.model.DistributionSet; +import org.eclipse.hawkbit.repository.model.Target; +import org.eclipse.hawkbit.repository.model.TargetType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.ContextConfiguration; + +@ContextConfiguration(classes = { DefaultAccessControllerConfiguration.class }) +class ActionAccessControllerTest extends AbstractJpaIntegrationTest { + + private TargetType targetType1; + private TargetType targetType2; + private Target target1; + private Target target2; + private Action action11; + private Action action21; + private Action action22; + + @BeforeEach + void setUp() { + targetType1 = testdataFactory.createTargetType("Type1", Set.of()); + targetType2 = testdataFactory.createTargetType("Type2", Set.of()); + target1 = testdataFactory.createTarget("controller1", "Controller 1", targetType1); + target2 = testdataFactory.createTarget("controller2", "Controller 2", targetType2); + final DistributionSet ds = testdataFactory.createDistributionSet(); + action11 = createAction(target1, ds); + action21 = createAction(target2, ds); + action22 = createAction(target2, ds); + } + + @Test + void filterByControllerId() { + runAs(withUser("user", READ_TARGET + "/controllerId==" + target1.getControllerId()), () -> { + assertThat(deploymentManagement.findAction(action11.getId())).isPresent(); + assertThat(deploymentManagement.findAction(action21.getId())).isEmpty(); + assertThat(deploymentManagement.findAction(action22.getId())).isEmpty(); + assertThat(deploymentManagement.findActionsAll(UNPAGED).getContent()).hasSize(1); + }); + runAs(withUser("user", READ_TARGET + "/controllerId==" + target2.getControllerId()), () -> { + assertThat(deploymentManagement.findAction(action11.getId())).isEmpty(); + assertThat(deploymentManagement.findAction(action21.getId())).isPresent(); + assertThat(deploymentManagement.findAction(action22.getId())).isPresent(); + assertThat(deploymentManagement.findActionsAll(UNPAGED).getContent()).hasSize(2); + }); + } + + @Test + void filterByTargetTypeId() { + runAs(withUser("user", READ_TARGET + "/type.id==" + targetType1.getId()), () -> { + assertThat(deploymentManagement.findAction(action11.getId())).isPresent(); + assertThat(deploymentManagement.findAction(action21.getId())).isEmpty(); + assertThat(deploymentManagement.findAction(action22.getId())).isEmpty(); + assertThat(deploymentManagement.findActionsAll(UNPAGED).getContent()).hasSize(1); + }); + runAs(withUser("user", READ_TARGET + "/type.id=in=" + targetType2.getId()), () -> { + assertThat(deploymentManagement.findAction(action11.getId())).isEmpty(); + assertThat(deploymentManagement.findAction(action21.getId())).isPresent(); + assertThat(deploymentManagement.findAction(action22.getId())).isPresent(); + assertThat(deploymentManagement.findActionsAll(UNPAGED).getContent()).hasSize(2); + }); + } + + private Action createAction(final Target target, final DistributionSet ds) { + final JpaAction generateAction = new JpaAction(); + generateAction.setActionType(Action.ActionType.FORCED); + generateAction.setTarget(target); + generateAction.setDistributionSet(ds); + generateAction.setStatus(Action.Status.RUNNING); + generateAction.setInitiatedBy("DEFAULT"); + generateAction.setWeight(1000); + + return actionRepository.save(generateAction); + } +} \ No newline at end of file