diff --git a/application/src/main/java/org/thingsboard/server/controller/RuleEngineController.java b/application/src/main/java/org/thingsboard/server/controller/RuleEngineController.java index d63e0f5d19..e42ebad8d6 100644 --- a/application/src/main/java/org/thingsboard/server/controller/RuleEngineController.java +++ b/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 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 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 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 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 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 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 handleRuleEngineRequest(String entityType, + String entityIdStr, + int timeout, + String requestBody, + String queueName, + List 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 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. + * + *

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=[]}. + * + *

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 entities) { + if (entities == null || entities.isEmpty()) { + return "[]"; + } + Map cache = new HashMap<>(); + List 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 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 responseWriter = rpcRequest.responseWriter(); if (response == null) { diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 454c59d7d7..20bbc3939c 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/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 diff --git a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java index 055e1e19a0..37b7541702 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java +++ b/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(); diff --git a/application/src/test/java/org/thingsboard/server/controller/RuleEngineControllerV2EnrichmentTest.java b/application/src/test/java/org/thingsboard/server/controller/RuleEngineControllerV2EnrichmentTest.java new file mode 100644 index 0000000000..9fb967fe6e --- /dev/null +++ b/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 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 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 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 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 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 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 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 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 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() { + }, status().isOk()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TbMsg.class); + verify(ruleEngineCallService, atLeastOnce()).processRestApiCallToRuleEngine(eq(expectedTenantId), + any(UUID.class), captor.capture(), anyBoolean(), any(Consumer.class)); + List all = captor.getAllValues(); + return all.get(all.size() - 1); + } + + private List parseAcl(TbMsg msg) { + String acl = msg.getMetaData().getValue(TbMsgMetaData.ACL_KEY); + return JacksonUtil.fromString(acl, new TypeReference>() { + }); + } + + private void mockRestApiCallToRuleEngine(TbMsg responseMsg) { + doAnswer(invocation -> { + Consumer 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)); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/rule/engine/EntityAclEntry.java b/common/data/src/main/java/org/thingsboard/server/common/data/rule/engine/EntityAclEntry.java new file mode 100644 index 0000000000..4c88760f6b --- /dev/null +++ b/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 allowed; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/rule/engine/RuleEngineV2Request.java b/common/data/src/main/java/org/thingsboard/server/common/data/rule/engine/RuleEngineV2Request.java new file mode 100644 index 0000000000..ab81198451 --- /dev/null +++ b/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 aclEntities; +} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsgMetaData.java b/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsgMetaData.java index 454e6f01a6..df3dc4a519 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsgMetaData.java +++ b/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 data; public TbMsgMetaData() { diff --git a/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java b/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java index f133527ddc..42c3a4168a 100644 --- a/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java +++ b/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() { + }).getBody(); + } + public CalculatedField saveCalculatedField(CalculatedField calculatedField) { return restTemplate.postForEntity(baseURL + "/api/calculatedField", calculatedField, CalculatedField.class).getBody(); }