Skip to content
Draft
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 @@ -9,9 +9,12 @@
*/
package org.eclipse.hawkbit.security;

import java.time.Duration;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
Expand Down Expand Up @@ -124,6 +127,7 @@ public static class Dos {

private final Filter filter = new Filter();
private final Filter uiFilter = new Filter();
private final ControllerAttributes controllerAttributes = new ControllerAttributes();
/**
* Maximum number of status updates that the controller can report for
* an action (0 to disable).
Expand Down Expand Up @@ -224,5 +228,39 @@ public static class Filter {
*/
private int maxWrite = 50;
}

/**
* Throttling of not-requested (device-initiated without actual update) controller attribute updates.
*/
@Data
public static class ControllerAttributes {

/**
* Default minimum interval between accepted device-initiated attribute
* updates. ZERO (default) disables throttling for all tenants.
*/
private Duration minUpdateInterval = Duration.ZERO;

/**
* Per-tenant overrides of {@link #minUpdateInterval}, keyed by tenant name
* (case-insensitive). Tenants without an entry use {@link #minUpdateInterval}.
*/
private Map<String, Duration> perTenant = new HashMap<>();

/**
* @param tenant current tenant (may be {@code null})
* @return the configured minimum interval for the tenant, or the default
*/
public Duration intervalFor(final String tenant) {
if (tenant != null) {
for (final Map.Entry<String, Duration> entry : perTenant.entrySet()) {
if (entry.getKey().equalsIgnoreCase(tenant)) {
return entry.getValue();
}
}
}
return minUpdateInterval;
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* Copyright (c) 2015 Bosch Software Innovations GmbH and others
*
* 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.security;

import static org.assertj.core.api.Assertions.assertThat;

import java.time.Duration;

import org.eclipse.hawkbit.security.HawkbitSecurityProperties.Dos.ControllerAttributes;
import org.junit.jupiter.api.Test;

class HawkbitSecurityPropertiesTest {

@Test
void intervalForDefaultsToZeroWhenNothingConfigured() {
final ControllerAttributes props = new ControllerAttributes();
assertThat(props.intervalFor("TENANT")).isEqualTo(Duration.ZERO);
assertThat(props.intervalFor(null)).isEqualTo(Duration.ZERO);
}

@Test
void intervalForFallsBackToDefaultWhenTenantNotListed() {
final ControllerAttributes props = new ControllerAttributes();
props.setMinUpdateInterval(Duration.ofMinutes(2));
assertThat(props.intervalFor("UNLISTED")).isEqualTo(Duration.ofMinutes(2));
}

@Test
void intervalForResolvesPerTenantOverrideCaseInsensitively() {
final ControllerAttributes props = new ControllerAttributes();
props.setMinUpdateInterval(Duration.ofMinutes(2));
props.getPerTenant().put("Abusive", Duration.ofMinutes(5));
assertThat(props.intervalFor("ABUSIVE")).isEqualTo(Duration.ofMinutes(5));
assertThat(props.intervalFor("abusive")).isEqualTo(Duration.ofMinutes(5));
assertThat(props.intervalFor("OTHER")).isEqualTo(Duration.ofMinutes(2));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"controllerId" : "137",
"updateStatus" : "in_sync",
"lastControllerRequestAt" : 1682408577978,
"lastControllerAttributesUpdate" : 1682408577998,
"installedAt" : 1682408577987,
"ipAddress" : "192.168.0.1",
"address" : "http://192.168.0.1",
Expand Down Expand Up @@ -115,6 +116,9 @@ public class MgmtTarget extends MgmtNamedEntity {
@Schema(description = "Timestamp of the last controller request", example = "1691065941102")
private Long lastControllerRequestAt;

@Schema(description = "Timestamp of the last controller attributes (config data) update", example = "1691065941102")
private Long lastControllerAttributesUpdate;

@Schema(description = "Install timestamp", example = "1691065941155")
private Long installedAt;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,10 @@ public static MgmtTarget toResponse(final Target target, final Function<Target,
if (lastTargetQuery != null) {
targetRest.setLastControllerRequestAt(lastTargetQuery);
}
final Long lastControllerAttributesUpdate = target.getLastControllerAttributesUpdate();
if (lastControllerAttributesUpdate != null) {
targetRest.setLastControllerAttributesUpdate(lastControllerAttributesUpdate);
}
if (installationDate != null) {
targetRest.setInstalledAt(installationDate);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2828,6 +2828,23 @@ void shouldReturnNotAllowedWhenDeletingActionsNotEligibleForDeletion() throws Ex
.andExpect(status().isMethodNotAllowed());
}

@Test
void targetResponseContainsLastControllerAttributesUpdate() throws Exception {
final String knownTargetId = "attrTs";
testdataFactory.createTarget(knownTargetId);

// before any attributes update the timestamp is absent
mvc.perform(get(TARGETS_V1 + "/" + knownTargetId).contentType(APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.lastControllerAttributesUpdate").doesNotExist());

controllerManagement.updateControllerAttributes(knownTargetId, Map.of("k", "v"), null);

mvc.perform(get(TARGETS_V1 + "/" + knownTargetId).contentType(APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.lastControllerAttributesUpdate", notNullValue()));
}

private static Stream<Arguments> confirmationOptions() {
return Stream.of(Arguments.of(true, true), Arguments.of(true, false), Arguments.of(false, true),
Arguments.of(false, false), Arguments.of(true, null), Arguments.of(false, null));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ public interface Target extends NamedEntity, Identifiable<Long> {
*/
Long getLastTargetQuery();

/**
* @return timestamp (epoch millis) of the last accepted controller attributes update, or {@code null} if never updated
*/
Long getLastControllerAttributesUpdate();

/**
* @return time in {@link TimeUnit#MILLISECONDS} GMT when {@link #getInstalledDistributionSet()} was applied.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE sp_target ADD COLUMN last_controller_attributes_update BIGINT;
ALTER TABLE sp_target ADD COLUMN last_controller_attributes_update_requested BOOLEAN;
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ALTER TABLE sp_target
ADD COLUMN last_controller_attributes_update BIGINT,
ADD COLUMN last_controller_attributes_update_requested BOOLEAN;
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ALTER TABLE sp_target
ADD COLUMN last_controller_attributes_update BIGINT,
ADD COLUMN last_controller_attributes_update_requested BOOLEAN;
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.ListUtils;
import org.eclipse.hawkbit.context.AccessContext;
import org.eclipse.hawkbit.security.HawkbitSecurityProperties;
import org.eclipse.hawkbit.ql.jpa.QLSupport;
import org.eclipse.hawkbit.repository.ConfirmationManagement;
import org.eclipse.hawkbit.repository.ControllerManagement;
Expand Down Expand Up @@ -146,6 +147,7 @@ public class JpaControllerManagement extends JpaActionManagement implements Cont
private final ControllerPollProperties controllerPollProperties;
private final PlatformTransactionManager txManager;
private final EntityManager entityManager;
private final HawkbitSecurityProperties securityProperties;

private final Duration minPollingTime;
private final Duration maxPollingTime;
Expand All @@ -162,6 +164,7 @@ protected JpaControllerManagement(
final DistributionSetManagement<? extends DistributionSet> distributionSetManagement,
final ControllerPollProperties controllerPollProperties,
final PlatformTransactionManager txManager, final EntityManager entityManager,
final HawkbitSecurityProperties securityProperties,
final ScheduledExecutorService executorService) {
super(actionRepository, actionStatusRepository, quotaManagement, repositoryProperties);

Expand All @@ -175,6 +178,7 @@ protected JpaControllerManagement(
this.controllerPollProperties = controllerPollProperties;
this.txManager = txManager;
this.entityManager = entityManager;
this.securityProperties = securityProperties;

minPollingTime = controllerPollProperties.getMinPollingTime() == null
? Duration.of(0, ChronoUnit.SECONDS)
Expand Down Expand Up @@ -442,6 +446,19 @@ public Target updateControllerAttributes(final String controllerId, final Map<St
}

final JpaTarget jpaTarget = targetRepository.getByControllerId(controllerId);
final boolean requestedAttributes = jpaTarget.isRequestControllerAttributes();
final long now = System.currentTimeMillis();

if (!requestedAttributes) { // server did not request attributes update
final Duration updateInterval = securityProperties.getDos().getControllerAttributes().intervalFor(AccessContext.tenant());
final Long lastAttributesUpdate = jpaTarget.getLastControllerAttributesUpdate();
if (!updateInterval.isZero() &&
lastAttributesUpdate != null && Boolean.FALSE.equals(jpaTarget.getLastControllerAttributesUpdateRequested()) &&
now - lastAttributesUpdate < updateInterval.toMillis()) {
return jpaTarget; // silent no-op, no DB write
}
}

final UpdateMode updateMode = mode != null ? mode : UpdateMode.MERGE;

boolean targetChanged = false;
Expand Down Expand Up @@ -483,6 +500,10 @@ public Target updateControllerAttributes(final String controllerId, final Map<St
jpaTarget.setRequestControllerAttributes(false);
targetChanged = true;
}
if (targetChanged) {
jpaTarget.setLastControllerAttributesUpdate(now); // every accepted update (UI)
jpaTarget.setLastControllerAttributesUpdateRequested(requestedAttributes); // source, throttle-only
}
return targetChanged ? targetRepository.save(jpaTarget) : jpaTarget;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,16 @@ public class JpaTarget extends AbstractJpaNamedEntity implements Target, EventAw
@Column(name = "last_target_query")
private Long lastTargetQuery;

@Setter
@Getter
@Column(name = "last_controller_attributes_update")
private Long lastControllerAttributesUpdate;

@Setter
@Getter
@Column(name = "last_controller_attributes_update_requested")
private Boolean lastControllerAttributesUpdateRequested;

@Setter
@Getter
@Column(name = "install_date")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import static org.mockito.Mockito.when;

import java.io.ByteArrayInputStream;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
Expand All @@ -37,6 +38,7 @@
import org.assertj.core.api.Assertions;
import org.eclipse.hawkbit.auth.SpPermission;
import org.eclipse.hawkbit.repository.RepositoryProperties;
import org.eclipse.hawkbit.security.HawkbitSecurityProperties;
import org.eclipse.hawkbit.repository.TargetTypeManagement;
import org.eclipse.hawkbit.repository.UpdateMode;
import org.eclipse.hawkbit.repository.event.remote.CancelTargetAssignmentEvent;
Expand Down Expand Up @@ -80,6 +82,7 @@
import org.eclipse.hawkbit.repository.test.util.SecurityContextSwitch;
import org.eclipse.hawkbit.repository.test.util.TargetTestData;
import org.eclipse.hawkbit.repository.test.util.WithUser;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
Expand Down Expand Up @@ -2029,4 +2032,109 @@ void updateTargetAttributesNotSkippedWhenNullRemovesExistingKey() {
assertThat(result).doesNotContainKey("a");
assertThat(result).containsEntry("b", "2");
}

@Autowired
private HawkbitSecurityProperties securityProperties;

@AfterEach
void resetAttributeThrottle() {
securityProperties.getDos().getControllerAttributes().getPerTenant().clear();
}

/**
* Requested attribute updates (server requested them) are always accepted and are
* never throttled, even in rapid succession; the update source is recorded.
*/
@Test
void requestedAttributeUpdatesAreNeverThrottled() {
final String controllerId = "requested";
testdataFactory.createTarget(controllerId);
securityProperties.getDos().getControllerAttributes().setMinUpdateInterval(Duration.ofHours(1));

final WithUser controller = SecurityContextSwitch.withController("controller");
// target starts with requestControllerAttributes = true (requested)
runAs(controller, () -> writeAttributes(controllerId, 1, "a", "v1"));

// force it requested again (admin context) and update once more, still accepted
JpaTarget target = (JpaTarget) targetManagement.getByControllerId(controllerId);
target.setRequestControllerAttributes(true);
targetRepository.save(target);
runAs(controller, () -> writeAttributes(controllerId, 1, "b", "v2"));

target = (JpaTarget) targetManagement.getByControllerId(controllerId);
assertThat(targetManagement.getControllerAttributes(controllerId)).containsKeys("a0", "b0");
assertThat(target.getLastControllerAttributesUpdateRequested()).isTrue();
assertThat(target.getLastControllerAttributesUpdate()).isNotNull();
}

/**
* Two consecutive device-initiated updates within the interval: the second is dropped
* (no write) and returns silently.
*/
@Test
void deviceInitiatedUpdateWithinIntervalIsDropped() {
final String controllerId = "throttled";
testdataFactory.createTarget(controllerId);
securityProperties.getDos().getControllerAttributes().setMinUpdateInterval(Duration.ofHours(1));

final WithUser controller = SecurityContextSwitch.withController("controller");
runAs(controller, () -> {
// accepted - initially requestAttribute is true, internally requestAttribute is set to false, lastControllerAttributesUpdateRequested is set to true, lastControllerAttributesUpdate is set to now
writeAttributes(controllerId, 1, "initial", "v");
// accepted - requestAttribute is false, but lastControllerAttributesUpdateRequested is true - i.e. this update is first to be initated from device -> lastControllerAttributesUpdateRequested is set to false, lastControllerAttributesUpdate is ste to now
writeAttributes(controllerId, 1, "first-device-initiated", "v1");
// rejected - requestAttribute is false and lastControllerAttributesUpdateRequested is false and timeout has not passed
writeAttributes(controllerId, 1, "second-device-initiated", "v2");
});

assertThat(targetManagement.getControllerAttributes(controllerId))
.containsKeys("initial0", "first-device-initiated0")
.doesNotContainKey("second-device-initiated0");
}

/**
* A device-initiated update after the interval has elapsed is accepted.
*/
@Test
void deviceInitiatedUpdateAfterIntervalIsAccepted() {
final String controllerId = "elapsed";
testdataFactory.createTarget(controllerId);
securityProperties.getDos().getControllerAttributes().setMinUpdateInterval(Duration.ofHours(1));

final WithUser controller = SecurityContextSwitch.withController("controller");
runAs(controller, () -> {
writeAttributes(controllerId, 1, "initial", "v"); // accepted - initialize requestAttribute is true for new devices
writeAttributes(controllerId, 1, "first-device-initiated", "v1"); // device-initiated, accepted, stamps now
});

// backdate the stamp beyond the interval
final JpaTarget backdated = (JpaTarget) targetManagement.getByControllerId(controllerId);
backdated.setLastControllerAttributesUpdate(System.currentTimeMillis() - Duration.ofHours(2).toMillis());
targetRepository.save(backdated);

runAs(controller, () -> writeAttributes(controllerId, 1, "second-device-initiated0", "v2")); // device-initiated, elapsed -> accepted

assertThat(targetManagement.getControllerAttributes(controllerId))
.containsKeys("initial0", "first-device-initiated0", "second-device-initiated0");
}

/**
* With throttling disabled (default 0), rapid device-initiated updates are all accepted.
*/
@Test
void deviceInitiatedUpdatesNotThrottledWhenDisabled() {
final String controllerId = "disabled";
testdataFactory.createTarget(controllerId);
// minUpdateInterval left at ZERO (default)

final WithUser controller = SecurityContextSwitch.withController("controller");
runAs(controller, () -> {
writeAttributes(controllerId, 1, "initial", "v");
writeAttributes(controllerId, 1, "first-device-initiated", "v1");
writeAttributes(controllerId, 1, "second-device-initiated", "v2");
});

assertThat(targetManagement.getControllerAttributes(controllerId))
.containsKeys("initial0", "first-device-initiated0", "second-device-initiated0");
}
}
Loading