From 66aeb310c58b1f32cecf71ee1c1d0d70c24f2245 Mon Sep 17 00:00:00 2001 From: Oleksandra Matviienko Date: Wed, 13 May 2026 09:28:06 +0200 Subject: [PATCH] Fixed /v2 null-payload check and renamed acl/userId metadata keys The null-payload check in handleRuleEngineRequestV2 used `getPayload() == null`, but Jackson deserializes JSON `null` into NullNode rather than Java null, so a request body with `"payload": null` bypassed the check and reached the rule engine, returning 200 instead of 400. Added an `isNull()` guard alongside the Java-null one. Renamed the two new metadata keys to drop the `tb_` prefix: TB_ACL_SNAPSHOT_KEY ("tb_aclSnapshot") -> ACL_KEY ("acl") TB_USER_ID_KEY ("tb_userId") -> USER_ID_KEY ("userId") `userId` already exists as a rule-engine metadata key set by EntityActionService and BaseUserProcessor for the same semantic (caller user's UUID), so the rename aligns the v2 enrichment with the existing convention; `acl` has no other in-codebase consumer. --- .../controller/RuleEngineController.java | 17 +++++++++------- .../RuleEngineControllerV2EnrichmentTest.java | 20 +++++++++---------- .../server/common/msg/TbMsgMetaData.java | 4 ++-- 3 files changed, 22 insertions(+), 19 deletions(-) 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 fd270c7f36..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; @@ -86,9 +87,9 @@ public class RuleEngineController extends BaseController { 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" + - " * **`tb_aclSnapshot`** — a JSON array of `{entityId, allowed[]}` computed for every entity the caller lists in `aclEntities`. " + + " * **`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" + - " * **`tb_userId`** — UUID of the calling user, intended for audit logging inside rule chains.\n\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, ...) " + @@ -121,7 +122,9 @@ public class RuleEngineController extends BaseController { public DeferredResult handleRuleEngineRequestV2( @Parameter(description = "Enriched request body containing `payload` and optional `aclEntities`.", required = true) @RequestBody RuleEngineV2Request request) throws ThingsboardException { - if (request == null || request.getPayload() == null) { + 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); } @@ -132,7 +135,7 @@ public class RuleEngineController extends BaseController { // 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(request.getPayload()), request.getQueueName(), aclEntities); + return handleRuleEngineRequest(entityTypeStr, entityIdStr, timeout, JacksonUtil.toString(payload), request.getQueueName(), aclEntities); } @ApiOperation(value = "Push user message to the rule engine (handleRuleEngineRequestForUser)", @@ -259,8 +262,8 @@ public class RuleEngineController extends BaseController { 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.TB_USER_ID_KEY, currentUser.getId().getId().toString()); - metaData.put(TbMsgMetaData.TB_ACL_SNAPSHOT_KEY, buildAclSnapshot(currentUser, aclEntities)); + metaData.put(TbMsgMetaData.USER_ID_KEY, currentUser.getId().getId().toString()); + metaData.put(TbMsgMetaData.ACL_KEY, buildAclSnapshot(currentUser, aclEntities)); } TbMsg msg = TbMsg.newMsg() @@ -314,7 +317,7 @@ public class RuleEngineController extends BaseController { * 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#TB_ACL_SNAPSHOT_KEY}. + * @return serialized JSON array suitable for writing into {@link TbMsgMetaData#ACL_KEY}. */ private String buildAclSnapshot(SecurityUser user, List entities) { if (entities == null || entities.isEmpty()) { diff --git a/application/src/test/java/org/thingsboard/server/controller/RuleEngineControllerV2EnrichmentTest.java b/application/src/test/java/org/thingsboard/server/controller/RuleEngineControllerV2EnrichmentTest.java index ecf6e9bf09..9fb967fe6e 100644 --- a/application/src/test/java/org/thingsboard/server/controller/RuleEngineControllerV2EnrichmentTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/RuleEngineControllerV2EnrichmentTest.java @@ -76,7 +76,7 @@ public class RuleEngineControllerV2EnrichmentTest extends AbstractControllerTest TbMsg captured = doRequestAndCapture(request, tenantId); - assertThat(captured.getMetaData().getValue(TbMsgMetaData.TB_USER_ID_KEY)) + assertThat(captured.getMetaData().getValue(TbMsgMetaData.USER_ID_KEY)) .isEqualTo(tenantAdminUserId.getId().toString()); List acl = parseAcl(captured); assertThat(acl).hasSize(1); @@ -166,8 +166,8 @@ public class RuleEngineControllerV2EnrichmentTest extends AbstractControllerTest // aclEntities left null TbMsg captured = doRequestAndCapture(request, tenantId); - assertThat(captured.getMetaData().getValue(TbMsgMetaData.TB_ACL_SNAPSHOT_KEY)).isEqualTo("[]"); - assertThat(captured.getMetaData().getValue(TbMsgMetaData.TB_USER_ID_KEY)) + assertThat(captured.getMetaData().getValue(TbMsgMetaData.ACL_KEY)).isEqualTo("[]"); + assertThat(captured.getMetaData().getValue(TbMsgMetaData.USER_ID_KEY)) .isEqualTo(tenantAdminUserId.getId().toString()); } @@ -179,8 +179,8 @@ public class RuleEngineControllerV2EnrichmentTest extends AbstractControllerTest request.setAclEntities(List.of()); TbMsg captured = doRequestAndCapture(request, tenantId); - assertThat(captured.getMetaData().getValue(TbMsgMetaData.TB_ACL_SNAPSHOT_KEY)).isEqualTo("[]"); - assertThat(captured.getMetaData().getValue(TbMsgMetaData.TB_USER_ID_KEY)) + assertThat(captured.getMetaData().getValue(TbMsgMetaData.ACL_KEY)).isEqualTo("[]"); + assertThat(captured.getMetaData().getValue(TbMsgMetaData.USER_ID_KEY)) .isEqualTo(tenantAdminUserId.getId().toString()); } @@ -260,15 +260,15 @@ public class RuleEngineControllerV2EnrichmentTest extends AbstractControllerTest Device device = createDevice("dev-inj", "tok-inj"); RuleEngineV2Request request = baseRequest(); - request.setPayload(JacksonUtil.toJsonNode("{\"" + TbMsgMetaData.TB_ACL_SNAPSHOT_KEY + "\":\"attack\",\"" + - TbMsgMetaData.TB_USER_ID_KEY + "\":\"intruder\"}")); + 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.TB_ACL_SNAPSHOT_KEY)).contains("\"entityType\":\"DEVICE\""); - assertThat(captured.getMetaData().getValue(TbMsgMetaData.TB_USER_ID_KEY)) + assertThat(captured.getMetaData().getValue(TbMsgMetaData.ACL_KEY)).contains("\"entityType\":\"DEVICE\""); + assertThat(captured.getMetaData().getValue(TbMsgMetaData.USER_ID_KEY)) .isEqualTo(tenantAdminUserId.getId().toString()); } @@ -323,7 +323,7 @@ public class RuleEngineControllerV2EnrichmentTest extends AbstractControllerTest } private List parseAcl(TbMsg msg) { - String acl = msg.getMetaData().getValue(TbMsgMetaData.TB_ACL_SNAPSHOT_KEY); + String acl = msg.getMetaData().getValue(TbMsgMetaData.ACL_KEY); return JacksonUtil.fromString(acl, new TypeReference>() { }); } 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 83ce4653c3..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 @@ -35,14 +35,14 @@ public final class TbMsgMetaData implements Serializable { * 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 TB_ACL_SNAPSHOT_KEY = "tb_aclSnapshot"; + 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 TB_USER_ID_KEY = "tb_userId"; + public static final String USER_ID_KEY = "userId"; private final Map data;