Browse Source

Merge 66aeb310c5 into fea19e2b04

pull/15618/merge
Oleksandra Matviienko 2 days ago
committed by GitHub
parent
commit
7a78fbcbaf
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 172
      application/src/main/java/org/thingsboard/server/controller/RuleEngineController.java
  2. 7
      application/src/main/resources/thingsboard.yml
  3. 2
      application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java
  4. 340
      application/src/test/java/org/thingsboard/server/controller/RuleEngineControllerV2EnrichmentTest.java
  5. 38
      common/data/src/main/java/org/thingsboard/server/common/data/rule/engine/EntityAclEntry.java
  6. 44
      common/data/src/main/java/org/thingsboard/server/common/data/rule/engine/RuleEngineV2Request.java
  7. 15
      common/message/src/main/java/org/thingsboard/server/common/msg/TbMsgMetaData.java
  8. 10
      rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java

172
application/src/main/java/org/thingsboard/server/controller/RuleEngineController.java

@ -15,6 +15,7 @@
*/
package org.thingsboard.server.controller;
import com.fasterxml.jackson.databind.JsonNode;
import com.google.common.util.concurrent.FutureCallback;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
@ -27,6 +28,7 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
@ -34,24 +36,36 @@ import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.async.DeferredResult;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.HasTenantId;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.audit.ActionType;
import org.thingsboard.server.common.data.exception.ThingsboardErrorCode;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.EntityIdFactory;
import org.thingsboard.server.common.data.id.HasId;
import org.thingsboard.server.common.data.msg.TbMsgType;
import org.thingsboard.server.common.data.rule.engine.EntityAclEntry;
import org.thingsboard.server.common.data.rule.engine.RuleEngineV2Request;
import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.common.msg.TbMsgMetaData;
import org.thingsboard.server.config.annotations.ApiOperation;
import org.thingsboard.server.dao.entity.EntityService;
import org.thingsboard.server.exception.ToErrorResponseEntity;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.ruleengine.RuleEngineCallService;
import org.thingsboard.server.service.security.AccessValidator;
import org.thingsboard.server.service.security.model.SecurityUser;
import org.thingsboard.server.service.security.permission.Operation;
import org.thingsboard.server.service.security.permission.Resource;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.TimeoutException;
@ -64,9 +78,6 @@ import static org.thingsboard.server.controller.ControllerConstants.ENTITY_TYPE_
@Slf4j
public class RuleEngineController extends BaseController {
@Value("${server.rest.rule_engine.response_timeout:10000}")
public int defaultResponseTimeout;
private static final String MSG_DESCRIPTION_PREFIX = "Creates the Message with type 'REST_API_REQUEST' and payload taken from the request body. ";
private static final String MSG_DESCRIPTION = "This method allows you to extend the regular platform API with the power of Rule Engine. You may use default and custom rule nodes to handle the message. " +
"The generated message contains two important metadata fields:\n\n" +
@ -74,13 +85,62 @@ public class RuleEngineController extends BaseController {
" * **'requestUUID'** to identify the request and route possible response from the Rule Engine;\n\n" +
"Use **'rest call reply'** rule node to push the reply from rule engine back as a REST API call response. ";
private static final String V2_DESCRIPTION = "Variant of the Rule Engine REST API that enriches the forwarded `TbMsg` with two " +
"server-authoritative metadata keys before pushing it to the rule engine:\n\n" +
" * **`acl`** — a JSON array of `{entityId, allowed[]}` computed for every entity the caller lists in `aclEntities`. " +
"The `allowed` array contains the `Operation` values the caller has on the specific instance;\n" +
" * **`userId`** — UUID of the calling user, intended for audit logging inside rule chains.\n\n" +
"Caller-supplied values for either key are overwritten by the platform. This endpoint preserves the existing v1 behavior " +
"(timeout, queue routing, REST Call Reply) — the enrichment is additive.\n\n" +
"Note: `SYS_ADMIN` callers operate against the system tenant, so tenant-scoped entities (DEVICE, ASSET, ...) " +
"passed in `aclEntities` will produce `allowed=[]`.";
@Value("${server.rest.rule_engine.response_timeout:10000}")
public int defaultResponseTimeout;
/**
* Maximum number of entities accepted for ACL enrichment in a single
* {@code /api/rule-engine/v2} request. Each entity triggers N permission checks
* (one per {@link Operation} value), so the bound prevents excessive work per call.
* Set to 0 to disable the bound entirely (no upper limit).
*/
@Value("${rule_engine.acl.max_entities:20}")
private int maxAclEntities;
@Autowired
private RuleEngineCallService ruleEngineCallService;
@Autowired
private AccessValidator accessValidator;
@Autowired
private EntityService entityService;
@ApiOperation(value = "Push user message with ACL enrichment to the rule engine (handleRuleEngineRequestV2)",
notes = V2_DESCRIPTION + "\n\nUses the originator from the request body, or the calling User id when omitted."
+ "\n\n" + ControllerConstants.SECURITY_WRITE_CHECK)
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
@PostMapping("/v2")
public DeferredResult<ResponseEntity> handleRuleEngineRequestV2(
@Parameter(description = "Enriched request body containing `payload` and optional `aclEntities`.", required = true)
@RequestBody RuleEngineV2Request request) throws ThingsboardException {
JsonNode payload = request != null ? request.getPayload() : null;
// Jackson maps JSON `null` to NullNode (not Java null), so check isNull() too.
if (payload == null || payload.isNull()) {
throw new ThingsboardException("Request body with 'payload' is required",
ThingsboardErrorCode.BAD_REQUEST_PARAMS);
}
int timeout = request.getTimeout() > 0 ? request.getTimeout() : defaultResponseTimeout;
EntityId originator = request.getOriginator();
String entityTypeStr = originator != null ? originator.getEntityType().name() : null;
String entityIdStr = originator != null ? originator.getId().toString() : null;
// Always non-null on the v2 path so the shared helper writes the server-authoritative
// tb_user_id / tb_acl_snapshot metadata keys. v1 wrappers pass null and skip those writes.
List<EntityId> aclEntities = request.getAclEntities() != null ? request.getAclEntities() : List.of();
return handleRuleEngineRequest(entityTypeStr, entityIdStr, timeout, JacksonUtil.toString(payload), request.getQueueName(), aclEntities);
}
@ApiOperation(value = "Push user message to the rule engine (handleRuleEngineRequestForUser)",
notes = MSG_DESCRIPTION_PREFIX +
notes = "Deprecated since 4.3.1.2. Prefer `POST /api/rule-engine/v2` which accepts all routing parameters in the request body and supports optional ACL enrichment.\n\n" +
MSG_DESCRIPTION_PREFIX +
"Uses current User Id ( the one which credentials is used to perform the request) as the Rule Engine message originator. " +
MSG_DESCRIPTION +
"The default timeout of the request processing is 10 seconds."
@ -88,15 +148,17 @@ public class RuleEngineController extends BaseController {
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
@RequestMapping(value = "/", method = RequestMethod.POST)
@ResponseBody
@Deprecated(since = "4.3.1.2")
public DeferredResult<ResponseEntity> handleRuleEngineRequestForUser(
@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON object representing the message.", required = true,
content = @Content(mediaType = "text/plain", schema = @Schema(type = "string")))
@RequestBody String requestBody) throws ThingsboardException {
return handleRuleEngineRequestForEntityWithQueueAndTimeout(null, null, null, defaultResponseTimeout, requestBody);
return handleRuleEngineRequest(null, null, defaultResponseTimeout, requestBody, null, null);
}
@ApiOperation(value = "Push entity message to the rule engine (handleRuleEngineRequestForEntity)",
notes = MSG_DESCRIPTION_PREFIX +
notes = "Deprecated since 4.3.1.2. Prefer `POST /api/rule-engine/v2` which accepts all routing parameters in the request body and supports optional ACL enrichment.\n\n" +
MSG_DESCRIPTION_PREFIX +
"Uses specified Entity Id as the Rule Engine message originator. " +
MSG_DESCRIPTION +
"The default timeout of the request processing is 10 seconds."
@ -104,6 +166,7 @@ public class RuleEngineController extends BaseController {
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
@RequestMapping(value = "/{entityType}/{entityId}", method = RequestMethod.POST)
@ResponseBody
@Deprecated(since = "4.3.1.2")
public DeferredResult<ResponseEntity> handleRuleEngineRequestForEntity(
@Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true)
@PathVariable("entityType") String entityType,
@ -112,11 +175,12 @@ public class RuleEngineController extends BaseController {
@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON object representing the message.", required = true,
content = @Content(mediaType = "text/plain", schema = @Schema(type = "string")))
@RequestBody String requestBody) throws ThingsboardException {
return handleRuleEngineRequestForEntityWithQueueAndTimeout(entityType, entityIdStr, null, defaultResponseTimeout, requestBody);
return handleRuleEngineRequest(entityType, entityIdStr, defaultResponseTimeout, requestBody, null, null);
}
@ApiOperation(value = "Push entity message with timeout to the rule engine (handleRuleEngineRequestForEntityWithTimeout)",
notes = MSG_DESCRIPTION_PREFIX +
notes = "Deprecated since 4.3.1.2. Prefer `POST /api/rule-engine/v2` which accepts all routing parameters in the request body and supports optional ACL enrichment.\n\n" +
MSG_DESCRIPTION_PREFIX +
"Uses specified Entity Id as the Rule Engine message originator. " +
MSG_DESCRIPTION +
"The platform expects the timeout value in milliseconds."
@ -124,6 +188,7 @@ public class RuleEngineController extends BaseController {
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
@RequestMapping(value = "/{entityType}/{entityId}/{timeout}", method = RequestMethod.POST)
@ResponseBody
@Deprecated(since = "4.3.1.2")
public DeferredResult<ResponseEntity> handleRuleEngineRequestForEntityWithTimeout(
@Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true)
@PathVariable("entityType") String entityType,
@ -134,11 +199,12 @@ public class RuleEngineController extends BaseController {
@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON object representing the message.", required = true,
content = @Content(mediaType = "text/plain", schema = @Schema(type = "string")))
@RequestBody String requestBody) throws ThingsboardException {
return handleRuleEngineRequestForEntityWithQueueAndTimeout(entityType, entityIdStr, null, timeout, requestBody);
return handleRuleEngineRequest(entityType, entityIdStr, timeout, requestBody, null, null);
}
@ApiOperation(value = "Push entity message with timeout and specified queue to the rule engine (handleRuleEngineRequestForEntityWithQueueAndTimeout)",
notes = MSG_DESCRIPTION_PREFIX +
notes = "Deprecated since 4.3.1.2. Prefer `POST /api/rule-engine/v2` which accepts all routing parameters in the request body and supports optional ACL enrichment.\n\n" +
MSG_DESCRIPTION_PREFIX +
"Uses specified Entity Id as the Rule Engine message originator. " +
MSG_DESCRIPTION +
"If request sent for Device/Device Profile or Asset/Asset Profile entity, specified queue will be used instead of the queue selected in the device or asset profile. " +
@ -147,6 +213,7 @@ public class RuleEngineController extends BaseController {
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
@RequestMapping(value = "/{entityType}/{entityId}/{queueName}/{timeout}", method = RequestMethod.POST)
@ResponseBody
@Deprecated(since = "4.3.1.2")
public DeferredResult<ResponseEntity> handleRuleEngineRequestForEntityWithQueueAndTimeout(
@Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true)
@PathVariable("entityType") String entityType,
@ -159,6 +226,15 @@ public class RuleEngineController extends BaseController {
@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON object representing the message.", required = true,
content = @Content(mediaType = "text/plain", schema = @Schema(type = "string")))
@RequestBody String requestBody) throws ThingsboardException {
return handleRuleEngineRequest(entityType, entityIdStr, timeout, requestBody, queueName, null);
}
private DeferredResult<ResponseEntity> handleRuleEngineRequest(String entityType,
String entityIdStr,
int timeout,
String requestBody,
String queueName,
List<EntityId> aclEntities) throws ThingsboardException {
try {
SecurityUser currentUser = getCurrentUser();
EntityId entityId;
@ -167,6 +243,11 @@ public class RuleEngineController extends BaseController {
} else {
entityId = EntityIdFactory.getByTypeAndId(entityType, entityIdStr);
}
if (maxAclEntities > 0 && aclEntities != null && aclEntities.size() > maxAclEntities) {
throw new ThingsboardException(
"Exceeded max ACL enrichment entities: " + maxAclEntities,
ThingsboardErrorCode.BAD_REQUEST_PARAMS);
}
//Check that this is a valid JSON
JacksonUtil.toJsonNode(requestBody);
final DeferredResult<ResponseEntity> response = new DeferredResult<>();
@ -179,6 +260,12 @@ public class RuleEngineController extends BaseController {
metaData.put("serviceId", serviceInfoProvider.getServiceId());
metaData.put("requestUUID", requestId.toString());
metaData.put("expirationTime", Long.toString(expTime));
if (aclEntities != null) {
// v2 path: server-authoritative keys written last so any caller value is overwritten.
metaData.put(TbMsgMetaData.USER_ID_KEY, currentUser.getId().getId().toString());
metaData.put(TbMsgMetaData.ACL_KEY, buildAclSnapshot(currentUser, aclEntities));
}
TbMsg msg = TbMsg.newMsg()
.queueName(queueName)
.type(TbMsgType.REST_API_REQUEST)
@ -209,6 +296,71 @@ public class RuleEngineController extends BaseController {
}
}
/**
* Computes the ACL snapshot for the requested entities under the given user.
* Repeated {@link EntityId} values are deduplicated via a per-request cache so the
* DB fetch and the per-operation permission probes happen once per unique id; the
* output list preserves duplicates and input order.
*
* <p>For each unique entity, the target is loaded via
* {@link EntityService#fetchEntity(org.thingsboard.server.common.data.id.TenantId, EntityId)},
* the target {@link Resource} is resolved via {@link Resource#of(org.thingsboard.server.common.data.EntityType)},
* then every {@link Operation} value is probed via
* {@link org.thingsboard.server.service.security.permission.AccessControlService#hasPermission(SecurityUser, Resource, Operation, EntityId, HasTenantId)}
* so that ownership, customer hierarchy, and group membership of the specific
* instance are taken into account. Operations returning {@code true} are accumulated
* into the entry. Entries with unmapped EntityTypes, missing entities, entities
* whose type has no registered {@link org.thingsboard.server.dao.entity.EntityDaoService},
* or entities that are not {@link HasTenantId} produce {@code allowed=[]}.
*
* <p>Note: a {@code SYS_ADMIN} caller operates against the system tenant, so tenant-scoped
* entities (DEVICE, ASSET, ...) won't be resolved by the tenant-filtered lookup and the
* entry resolves to {@code allowed=[]} ACL enrichment is effectively a no-op for SYS_ADMIN.
*
* @return serialized JSON array suitable for writing into {@link TbMsgMetaData#ACL_KEY}.
*/
private String buildAclSnapshot(SecurityUser user, List<EntityId> entities) {
if (entities == null || entities.isEmpty()) {
return "[]";
}
Map<EntityId, EntityAclEntry> cache = new HashMap<>();
List<EntityAclEntry> result = new ArrayList<>(entities.size());
for (EntityId entityId : entities) {
result.add(cache.computeIfAbsent(entityId, eid -> computeAclEntry(user, eid)));
}
return JacksonUtil.toString(result);
}
private EntityAclEntry computeAclEntry(SecurityUser user, EntityId entityId) {
Set<String> allowed = new LinkedHashSet<>();
Resource resource;
try {
resource = Resource.of(entityId.getEntityType());
} catch (IllegalArgumentException e) {
return new EntityAclEntry(entityId, allowed);
}
HasId<?> entity;
try {
entity = entityService.fetchEntity(user.getTenantId(), entityId).orElse(null);
} catch (IllegalArgumentException e) {
// EntityType has no registered EntityDaoService — treat as missing entity.
return new EntityAclEntry(entityId, allowed);
}
if (!(entity instanceof HasTenantId tenantEntity)) {
return new EntityAclEntry(entityId, allowed);
}
for (Operation op : EnumSet.allOf(Operation.class)) {
try {
if (accessControlService.hasPermission(user, resource, op, entityId, tenantEntity)) {
allowed.add(op.name());
}
} catch (ThingsboardException e) {
log.debug("[{}] ACL probe failed for {} {}: {}", user.getId(), op, entityId, e.getMessage());
}
}
return new EntityAclEntry(entityId, allowed);
}
private void reply(LocalRequestMetaData rpcRequest, TbMsg response) {
DeferredResult<ResponseEntity> responseWriter = rpcRequest.responseWriter();
if (response == null) {

7
application/src/main/resources/thingsboard.yml

@ -116,6 +116,13 @@ server:
# Default timeout for waiting response of REST API request to Rule Engine in milliseconds
response_timeout: "${DEFAULT_RULE_ENGINE_RESPONSE_TIMEOUT:10000}"
# Rule Engine REST API v2 settings (see /api/rule-engine/v2)
rule_engine:
acl:
# Maximum number of entities accepted in /api/rule-engine/v2 aclEntities. Bounds DB load and permission checks per request.
# Set to 0 to disable the bound entirely (no upper limit); any positive value caps the list size.
max_entities: "${RULE_ENGINE_ACL_MAX_ENTITIES:20}"
# Application info parameters
app:
# Application version

2
application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java

@ -533,7 +533,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
protected void loginDifferentCustomer() throws Exception {
if (savedDifferentCustomer != null) {
login(savedDifferentCustomer.getEmail(), CUSTOMER_USER_PASSWORD);
login(DIFFERENT_CUSTOMER_USER_EMAIL, DIFFERENT_CUSTOMER_USER_PASSWORD);
} else {
createDifferentCustomer();

340
application/src/test/java/org/thingsboard/server/controller/RuleEngineControllerV2EnrichmentTest.java

@ -0,0 +1,340 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.controller;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.mock.mockito.SpyBean;
import org.springframework.test.context.TestPropertySource;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.RuleNodeId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.msg.TbMsgType;
import org.thingsboard.server.common.data.rule.engine.EntityAclEntry;
import org.thingsboard.server.common.data.rule.engine.RuleEngineV2Request;
import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.common.msg.TbMsgMetaData;
import org.thingsboard.server.dao.entity.EntityService;
import org.thingsboard.server.dao.service.DaoSqlTest;
import org.thingsboard.server.service.ruleengine.RuleEngineCallService;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.function.Consumer;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@DaoSqlTest
@TestPropertySource(properties = "rule_engine.acl.max_entities=5")
public class RuleEngineControllerV2EnrichmentTest extends AbstractControllerTest {
private static final int MAX_ACL_ENTITIES = 5;
private static final String URL = "/api/rule-engine/v2";
private static final String RESPONSE_BODY = "{\"response\":\"ok\"}";
@SpyBean
private RuleEngineCallService ruleEngineCallService;
@SpyBean
private EntityService entityService;
@Test
public void testV2TenantAdminGetsFullAclOnOwnDevice() throws Exception {
loginTenantAdmin();
Device device = createDevice("dev-tenant", "tok-1");
RuleEngineV2Request request = baseRequest();
request.setAclEntities(List.of(device.getId()));
TbMsg captured = doRequestAndCapture(request, tenantId);
assertThat(captured.getMetaData().getValue(TbMsgMetaData.USER_ID_KEY))
.isEqualTo(tenantAdminUserId.getId().toString());
List<EntityAclEntry> acl = parseAcl(captured);
assertThat(acl).hasSize(1);
assertThat(acl.get(0).getEntityId()).isEqualTo(device.getId());
assertThat(acl.get(0).getEntityId().getEntityType()).isEqualTo(EntityType.DEVICE);
assertThat(acl.get(0).getAllowed()).contains("READ", "WRITE", "DELETE", "WRITE_TELEMETRY");
}
@Test
public void testV2CustomerUserGetsAclOnOwnDevice() throws Exception {
loginTenantAdmin();
Device device = createDevice("dev-customer", "tok-2");
assignDeviceToCustomer(device.getId(), customerId);
loginCustomerUser();
RuleEngineV2Request request = baseRequest();
request.setAclEntities(List.of(device.getId()));
TbMsg captured = doRequestAndCapture(request, tenantId);
List<EntityAclEntry> acl = parseAcl(captured);
assertThat(acl).hasSize(1);
assertThat(acl.get(0).getAllowed()).contains("READ", "WRITE", "READ_TELEMETRY", "WRITE_TELEMETRY");
}
@Test
public void testV2CustomerUserCannotWriteForeignDevice() throws Exception {
loginDifferentCustomer();
loginTenantAdmin();
Device foreignDevice = createDevice("dev-foreign", "tok-3");
assignDeviceToCustomer(foreignDevice.getId(), differentCustomerId);
loginCustomerUser();
RuleEngineV2Request request = baseRequest();
request.setAclEntities(List.of(foreignDevice.getId()));
TbMsg captured = doRequestAndCapture(request, tenantId);
List<EntityAclEntry> acl = parseAcl(captured);
assertThat(acl).hasSize(1);
assertThat(acl.get(0).getEntityId()).isEqualTo(foreignDevice.getId());
// Platform allows CLAIM_DEVICES on any tenant device by design; everything else must be denied.
assertThat(acl.get(0).getAllowed())
.doesNotContain("READ", "WRITE", "READ_TELEMETRY", "WRITE_TELEMETRY",
"READ_ATTRIBUTES", "WRITE_ATTRIBUTES", "READ_CREDENTIALS", "RPC_CALL");
}
@Test
public void testV2TwoCustomersSeeOnlyTheirOwnDevices() throws Exception {
loginDifferentCustomer();
loginTenantAdmin();
Device deviceA = createDevice("dev-A", "tok-A");
Device deviceB = createDevice("dev-B", "tok-B");
assignDeviceToCustomer(deviceA.getId(), customerId);
assignDeviceToCustomer(deviceB.getId(), differentCustomerId);
// Customer 1 (own A, foreign B)
loginCustomerUser();
RuleEngineV2Request req1 = baseRequest();
req1.setAclEntities(List.of(deviceA.getId(), deviceB.getId()));
TbMsg captured1 = doRequestAndCapture(req1, tenantId);
List<EntityAclEntry> acl1 = parseAcl(captured1);
assertThat(acl1).hasSize(2);
assertThat(acl1.get(0).getEntityId()).isEqualTo(deviceA.getId());
assertThat(acl1.get(0).getAllowed()).contains("WRITE", "READ_TELEMETRY");
assertThat(acl1.get(1).getEntityId()).isEqualTo(deviceB.getId());
assertThat(acl1.get(1).getAllowed()).doesNotContain("WRITE", "READ", "READ_TELEMETRY");
// Customer 2 (foreign A, own B).
loginDifferentCustomer();
RuleEngineV2Request req2 = baseRequest();
req2.setAclEntities(List.of(deviceA.getId(), deviceB.getId()));
TbMsg captured2 = doRequestAndCapture(req2, tenantId);
List<EntityAclEntry> acl2 = parseAcl(captured2);
assertThat(acl2).hasSize(2);
assertThat(acl2.get(0).getEntityId()).isEqualTo(deviceA.getId());
assertThat(acl2.get(0).getAllowed()).doesNotContain("WRITE", "READ", "READ_TELEMETRY");
assertThat(acl2.get(1).getEntityId()).isEqualTo(deviceB.getId());
assertThat(acl2.get(1).getAllowed()).contains("WRITE", "READ_TELEMETRY");
}
@Test
public void testV2NullAclEntitiesProducesEmptyAcl() throws Exception {
loginTenantAdmin();
RuleEngineV2Request request = baseRequest();
// aclEntities left null
TbMsg captured = doRequestAndCapture(request, tenantId);
assertThat(captured.getMetaData().getValue(TbMsgMetaData.ACL_KEY)).isEqualTo("[]");
assertThat(captured.getMetaData().getValue(TbMsgMetaData.USER_ID_KEY))
.isEqualTo(tenantAdminUserId.getId().toString());
}
@Test
public void testV2EmptyAclEntitiesListProducesEmptyAcl() throws Exception {
loginTenantAdmin();
RuleEngineV2Request request = baseRequest();
request.setAclEntities(List.of());
TbMsg captured = doRequestAndCapture(request, tenantId);
assertThat(captured.getMetaData().getValue(TbMsgMetaData.ACL_KEY)).isEqualTo("[]");
assertThat(captured.getMetaData().getValue(TbMsgMetaData.USER_ID_KEY))
.isEqualTo(tenantAdminUserId.getId().toString());
}
@Test
public void testV2DuplicateEntitiesPreservedInOutputAndDedupedInWork() throws Exception {
loginTenantAdmin();
Device device = createDevice("dev-dup", "tok-dup");
Device other = createDevice("dev-other", "tok-other");
RuleEngineV2Request request = baseRequest();
request.setAclEntities(List.of(device.getId(), device.getId(), other.getId()));
TbMsg captured = doRequestAndCapture(request, tenantId);
List<EntityAclEntry> acl = parseAcl(captured);
assertThat(acl).hasSize(3);
assertThat(acl.get(0).getEntityId()).isEqualTo(device.getId());
assertThat(acl.get(1).getEntityId()).isEqualTo(device.getId());
assertThat(acl.get(2).getEntityId()).isEqualTo(other.getId());
// Dedup: the duplicated id triggers one fetchEntity, not two.
verify(entityService, times(1)).fetchEntity(eq(tenantId), eq(device.getId()));
verify(entityService, times(1)).fetchEntity(eq(tenantId), eq(other.getId()));
}
@Test
public void testV2RejectsRequestExceedingMaxEntities() throws Exception {
loginTenantAdmin();
// bound is set via @TestPropertySource — test is independent of production default.
List<EntityId> tooMany = new ArrayList<>();
for (int i = 0; i < MAX_ACL_ENTITIES + 1; i++) {
tooMany.add(new DeviceId(UUID.randomUUID()));
}
RuleEngineV2Request request = baseRequest();
request.setAclEntities(tooMany);
doPost(URL, request).andExpect(status().isBadRequest());
}
@Test
public void testV2UnmappedEntityTypeProducesEmptyAcl() throws Exception {
loginTenantAdmin();
// RULE_NODE has no Resource mapping — Resource.of throws, entry resolves to allowed=[].
RuleNodeId fakeRuleNode = new RuleNodeId(UUID.randomUUID());
RuleEngineV2Request request = baseRequest();
request.setAclEntities(List.of(fakeRuleNode));
TbMsg captured = doRequestAndCapture(request, tenantId);
List<EntityAclEntry> acl = parseAcl(captured);
assertThat(acl).hasSize(1);
assertThat(acl.get(0).getEntityId().getEntityType()).isEqualTo(EntityType.RULE_NODE);
assertThat(acl.get(0).getAllowed()).isEmpty();
}
@Test
public void testV2NonexistentDeviceProducesEmptyAcl() throws Exception {
loginTenantAdmin();
DeviceId ghost = new DeviceId(UUID.randomUUID());
RuleEngineV2Request request = baseRequest();
request.setAclEntities(List.of(ghost));
TbMsg captured = doRequestAndCapture(request, tenantId);
List<EntityAclEntry> acl = parseAcl(captured);
assertThat(acl).hasSize(1);
assertThat(acl.get(0).getEntityId()).isEqualTo(ghost);
assertThat(acl.get(0).getAllowed()).isEmpty();
}
@Test
public void testV2PayloadCannotInjectAclMetadata() throws Exception {
loginTenantAdmin();
Device device = createDevice("dev-inj", "tok-inj");
RuleEngineV2Request request = baseRequest();
request.setPayload(JacksonUtil.toJsonNode("{\"" + TbMsgMetaData.ACL_KEY + "\":\"attack\",\"" +
TbMsgMetaData.USER_ID_KEY + "\":\"intruder\"}"));
request.setAclEntities(List.of(device.getId()));
TbMsg captured = doRequestAndCapture(request, tenantId);
// Server-computed values, not the attacker's.
assertThat(captured.getMetaData().getValue(TbMsgMetaData.ACL_KEY)).contains("\"entityType\":\"DEVICE\"");
assertThat(captured.getMetaData().getValue(TbMsgMetaData.USER_ID_KEY))
.isEqualTo(tenantAdminUserId.getId().toString());
}
@Test
public void testV2RequiresPayload() throws Exception {
loginTenantAdmin();
RuleEngineV2Request request = new RuleEngineV2Request();
// payload deliberately not set — the v2 contract now requires it.
doPost(URL, request).andExpect(status().isBadRequest());
}
@Test
public void testV2ForwardsRestApiRequestTypeAndHonorsBodyTimeout() throws Exception {
loginTenantAdmin();
RuleEngineV2Request request = baseRequest();
request.setTimeout(2000);
long beforeMs = System.currentTimeMillis();
TbMsg captured = doRequestAndCapture(request, tenantId);
long afterMs = System.currentTimeMillis();
assertThat(captured.getType()).isEqualTo(TbMsgType.REST_API_REQUEST.name());
long expirationTime = Long.parseLong(captured.getMetaData().getValue("expirationTime"));
assertThat(expirationTime).isBetween(beforeMs + 2000, afterMs + 2000);
}
private RuleEngineV2Request baseRequest() {
RuleEngineV2Request request = new RuleEngineV2Request();
request.setPayload(JacksonUtil.toJsonNode("{\"k\":\"v\"}"));
return request;
}
private TbMsg doRequestAndCapture(RuleEngineV2Request request, TenantId expectedTenantId) throws Exception {
TbMsg responseMsg = TbMsg.newMsg()
.type(TbMsgType.REST_API_REQUEST)
.originator(currentUserId)
.copyMetaData(TbMsgMetaData.EMPTY)
.data(RESPONSE_BODY)
.build();
mockRestApiCallToRuleEngine(responseMsg);
doPostAsyncWithTypedResponse(URL, request, new TypeReference<JsonNode>() {
}, status().isOk());
ArgumentCaptor<TbMsg> captor = ArgumentCaptor.forClass(TbMsg.class);
verify(ruleEngineCallService, atLeastOnce()).processRestApiCallToRuleEngine(eq(expectedTenantId),
any(UUID.class), captor.capture(), anyBoolean(), any(Consumer.class));
List<TbMsg> all = captor.getAllValues();
return all.get(all.size() - 1);
}
private List<EntityAclEntry> parseAcl(TbMsg msg) {
String acl = msg.getMetaData().getValue(TbMsgMetaData.ACL_KEY);
return JacksonUtil.fromString(acl, new TypeReference<List<EntityAclEntry>>() {
});
}
private void mockRestApiCallToRuleEngine(TbMsg responseMsg) {
doAnswer(invocation -> {
Consumer<TbMsg> consumer = invocation.getArgument(4);
consumer.accept(responseMsg);
return null;
}).when(ruleEngineCallService).processRestApiCallToRuleEngine(any(TenantId.class), any(UUID.class),
any(TbMsg.class), anyBoolean(), any(Consumer.class));
}
}

38
common/data/src/main/java/org/thingsboard/server/common/data/rule/engine/EntityAclEntry.java

@ -0,0 +1,38 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.common.data.rule.engine;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.thingsboard.server.common.data.id.EntityId;
import java.util.Set;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class EntityAclEntry {
@Schema(description = "Target entity identifier (entity type and UUID).")
private EntityId entityId;
@Schema(description = "Operations the caller is allowed to perform on the entity. " +
"Names match the platform Operation enum values (READ, WRITE, ...).")
private Set<String> allowed;
}

44
common/data/src/main/java/org/thingsboard/server/common/data/rule/engine/RuleEngineV2Request.java

@ -0,0 +1,44 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.common.data.rule.engine;
import com.fasterxml.jackson.databind.JsonNode;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import org.thingsboard.server.common.data.id.EntityId;
import java.util.List;
@Data
public class RuleEngineV2Request {
@Schema(description = "Originator of the forwarded TbMsg. When omitted, the calling User's id is used.")
private EntityId originator;
@Schema(description = "Optional rule engine queue name. Overrides the queue selected by device/asset profile when present.")
private String queueName;
@Schema(description = "Timeout to process the request, in milliseconds. When omitted or <= 0, the platform default is used.",
example = "10000")
private int timeout;
@Schema(description = "Message payload forwarded to the rule engine as TbMsg.data.", requiredMode = Schema.RequiredMode.REQUIRED)
private JsonNode payload;
@Schema(description = "Optional list of entities for which to compute the ACL snapshot. " +
"Size is bounded by the rule-engine.acl.max-entities configuration.")
private List<EntityId> aclEntities;
}

15
common/message/src/main/java/org/thingsboard/server/common/msg/TbMsgMetaData.java

@ -29,6 +29,21 @@ public final class TbMsgMetaData implements Serializable {
public static final TbMsgMetaData EMPTY = new TbMsgMetaData(0);
/**
* Reserved metadata key. Populated by the platform (e.g. the Rule Engine REST API
* v2 enrichment) with a JSON array of {@code EntityAclEntry}. Rule chains MUST
* treat this value as authoritative; the platform overwrites any caller-supplied
* value of this key before the message is forwarded to the Rule Engine.
*/
public static final String ACL_KEY = "acl";
/**
* Reserved metadata key. Populated by the platform with the UUID (as string) of
* the user that initiated the request intended for audit logging inside rule
* chains. The platform overwrites any caller-supplied value of this key.
*/
public static final String USER_ID_KEY = "userId";
private final Map<String, String> data;
public TbMsgMetaData() {

10
rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java

@ -185,6 +185,7 @@ import org.thingsboard.server.common.data.rule.RuleChain;
import org.thingsboard.server.common.data.rule.RuleChainData;
import org.thingsboard.server.common.data.rule.RuleChainMetaData;
import org.thingsboard.server.common.data.rule.RuleChainType;
import org.thingsboard.server.common.data.rule.engine.RuleEngineV2Request;
import org.thingsboard.server.common.data.security.DeviceCredentials;
import org.thingsboard.server.common.data.security.DeviceCredentialsType;
import org.thingsboard.server.common.data.security.model.JwtPair;
@ -4363,6 +4364,15 @@ public class RestClient implements Closeable {
timeout).getBody();
}
public JsonNode handleRuleEngineRequestV2(RuleEngineV2Request request) {
return restTemplate.exchange(
baseURL + "/api/rule-engine/v2",
HttpMethod.POST,
new HttpEntity<>(request),
new ParameterizedTypeReference<JsonNode>() {
}).getBody();
}
public CalculatedField saveCalculatedField(CalculatedField calculatedField) {
return restTemplate.postForEntity(baseURL + "/api/calculatedField", calculatedField, CalculatedField.class).getBody();
}

Loading…
Cancel
Save