From bc5bfe497415a89ebb1352f40041682500f9119d Mon Sep 17 00:00:00 2001 From: Oleksandra Matviienko Date: Tue, 12 May 2026 10:46:23 +0200 Subject: [PATCH 01/10] Added /api/rule-engine/v2 endpoint with per-entity ACL enrichment New endpoint accepts an enrichEntities list and writes tb_acl (per-entity capability snapshot) and tb_user_id into protected TbMsg metadata. v1 endpoints untouched. Default max-entities=20 via server.rest.rule_engine.acl.max_entities. --- ...roller-acl-enrichment-per-entity-design.md | 377 ++++++++++++++++++ .../controller/RuleEngineController.java | 142 +++++++ .../src/main/resources/thingsboard.yml | 3 + .../RuleEngineControllerV2Test.java | 318 +++++++++++++++ .../engine/EnrichedRuleEngineRequest.java | 36 ++ .../data/rule/engine/EntityAclEntry.java | 35 ++ .../server/common/msg/TbMsgMetaData.java | 3 + 7 files changed, 914 insertions(+) create mode 100644 RuleEngineController-acl-enrichment-per-entity-design.md create mode 100644 application/src/test/java/org/thingsboard/server/controller/RuleEngineControllerV2Test.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/rule/engine/EnrichedRuleEngineRequest.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/rule/engine/EntityAclEntry.java diff --git a/RuleEngineController-acl-enrichment-per-entity-design.md b/RuleEngineController-acl-enrichment-per-entity-design.md new file mode 100644 index 0000000000..0222398a9e --- /dev/null +++ b/RuleEngineController-acl-enrichment-per-entity-design.md @@ -0,0 +1,377 @@ +# Rule Engine ACL Enrichment — Per-Entity Permissions (Design v2) + +**Date:** 2026-05-12 +**Author:** Oleksandra Matviienko +**Status:** Approved for implementation; implementation merged in this PR +**Scope:** ThingsBoard CE (branch: based on `lts-4.3`) +**Supersedes:** the role-level design (issue #15496 / prior `RuleEngineController-acl-enrichment-design.md`) + +--- + +## 1. Problem Statement + +When the REST API pushes a message to the Rule Engine, the engine runs under effectively-root privileges. Rule chains can read, write, or delete any entity without knowing whether the *calling user* was actually authorized to do so. The only permission check happens in the controller against a single originator entity (`Operation.WRITE`); once the `TbMsg` enters the queue, user identity and authorization context are lost. + +This prevents rule-chain authors from building flows whose behavior depends on the caller's permissions — e.g., "allow this action only if the caller can WRITE to asset X", or "log who initiated this action". + +## 2. Goal + +Add an **optional, additive, non-breaking** enrichment path to the Rule Engine REST API that: + +1. Accepts a list of entities from the caller alongside the payload. +2. Computes, server-side, **per-entity** — the set of operations the calling user is allowed to perform on each *specific* entity instance. +3. Writes that ACL snapshot into **protected** `TbMsg` metadata keys the caller cannot override. +4. Also writes the caller's user id, for audit/logging inside rule chains. +5. Forwards the enriched `TbMsg` to the Rule Engine as usual. + +Rule chains are free to ignore the metadata entirely — existing flows are unaffected. + +## 3. Non-Goals + +- Denying the request at the API layer based on the ACL snapshot. Decisions live in the rule chain. +- Modifying or replacing the existing `POST /api/rule-engine/...` v1 endpoints. This adds new `/v2/` endpoint alongside them. +- Changing `AccessValidator`, `AccessControlService`, `Resource`, `Operation`, or permission-model semantics. We consume them as-is. +- UI changes. +- Extending enrichment to controllers other than `RuleEngineController` (see §13). + +## 4. API Contract + +A single new endpoint with all routing parameters in the request body: + +- `POST /api/rule-engine/v2/` + +Same authorization as v1: `hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')` and existing `AccessValidator` WRITE check on the originator. + +### Request body + +```json +{ + "originator": { "entityType": "DEVICE", "id": "784f394c-42b6-..." }, + "messageType": "REST_API_REQUEST", + "queueName": "Main", + "timeout": 10000, + "payload": { "any": "user json" }, + "enrichEntities": [ + { "entityType": "DEVICE", "id": "784f394c-42b6-..." }, + { "entityType": "ASSET", "id": "abc-123-..." } + ] +} +``` + +- `originator` — optional `EntityId`. If absent, defaults to the calling user's id (matches v1 behavior when path variables are omitted). +- `messageType` — optional. Defaults to `REST_API_REQUEST` (matches v1's hardcoded type — and what the platform documentation references as the input type for `rest call reply`, see https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/rest-call-reply/). Custom strings are accepted. +- `queueName` — optional. When present, overrides the queue selected by the originator's profile (same semantics as v1). +- `timeout` — optional, milliseconds. Defaults to `server.rest.rule_engine.response_timeout` (10s). +- `payload` — optional JSON. A null or missing payload is treated as the empty JSON object `{}` so probe-only requests (callers who want only the ACL snapshot) work without a body. +- `enrichEntities` — optional list of `EntityId` (uses the platform's existing polymorphic `EntityId` Jackson representation: `{entityType, id}`). If absent or empty, `tb_acl` is `"[]"`. +- Size limit: configurable via `server.rest.rule_engine.acl.max_entities` (default **20**). Exceeding returns `400 Bad Request`. + +### Response + +Unchanged from v1: `DeferredResult` populated by the Rule Engine's terminal "REST Call Reply" node (or `408`/`504` on timeout). + +## 5. Protected Metadata + +Two new keys, both **always written last** when building `TbMsgMetaData`, so any user-supplied value (if a caller tried to sneak them in via the payload or elsewhere) is unconditionally overwritten. + +### 5.1 `tb_acl` + +- Key constant: `TbMsgMetaData.TB_ACL_KEY = "tb_acl"` +- Value: JSON string (serialized `List`). +- Order: matches the order of `enrichEntities` in the request (deterministic). +- Duplicate `EntityId` values in `enrichEntities` produce duplicate entries in the output in the same positions; the underlying entity is loaded only once (dedup cache per request). +- Shape: + +```json +[ + { + "entityType": "DEVICE", + "entityId": "784f394c-42b6-...", + "allowed": ["READ", "WRITE", "READ_ATTRIBUTES", "WRITE_ATTRIBUTES", "READ_TELEMETRY"] + }, + { + "entityType": "ASSET", + "entityId": "abc-123-...", + "allowed": [] + } +] +``` + +- **`allowed` — per-entity capability**: names from the `Operation` enum the caller can perform on *this specific entity instance* (not on the resource type in the abstract). For Customer Users whose access depends on entity assignment, this correctly reflects whether the caller can act on the concrete entity. +- An empty `allowed` list means either: (a) the user has no operations on this entity, (b) the entity was not found / cross-tenant / not `HasTenantId`, or (c) the `EntityType` has no `Resource` mapping. Server-side WARN logs distinguish (b) and (c) for operations debugging; rule-chain authors can treat empty as a single "no access" signal. +- Format deliberately an array of flat objects (no compound keys like `"DEVICE:uuid"`) so it iterates naturally in JS and TBEL rule nodes. + +### 5.2 `tb_user_id` + +- Key constant: `TbMsgMetaData.TB_USER_ID_KEY = "tb_user_id"` +- Value: UUID string of the calling user (`currentUser.getId().getId().toString()`). +- Stable across rename/email changes. +- `tenantId` and `customerId` are already recoverable from `TbMsg` (originator, customerId), so they are not duplicated here. + +## 6. Computation Logic + +For each `EntityId` in `enrichEntities`, **after** size-limit validation: + +1. **Map `EntityType → Resource`** via existing `Resource.of(entityType)`. If it throws `IllegalArgumentException` (no `Resource` for this type — e.g., `RULE_NODE`), log WARN and record the entity with `allowed=[]`. Continue. +2. **Load the entity** via `entityServiceRegistry.getServiceByEntityType(entityType).findEntity(user.getTenantId(), entityId)`: + - The `tenantId` argument means the DAO-level filter rejects cross-tenant ids → `Optional.empty()`. + - If `getServiceByEntityType` throws `IllegalArgumentException` (no DAO registered for this type), log WARN, `allowed=[]`, continue. + - If the result is `Optional.empty()` (entity does not exist, cross-tenant, or was just deleted), log WARN, `allowed=[]`, continue. + - If the loaded object does not implement `HasTenantId` (rare system-level entity), log WARN, `allowed=[]`, continue. +3. **For each value in `Operation.values()`**: + - Call `accessControlService.hasPermission(user, resource, op, entityId, entity)` — the **per-entity, boolean-returning** form. + - `true` → add `op.name()` to `allowed`. + - `false` → skip silently (normal "you don't have this op" case). + - Throws `ThingsboardException` → skip silently (the rare "this authority has no checker for this resource" wiring case; defensive catch). +4. Append `new EntityAclEntry(entityType, entityId.getId(), allowed)` to the result list. + +### Deduplication + +`buildAclMetadata` keeps a `Map` cache for the duration of one request. If `enrichEntities` repeats the same id, the per-entity computation runs once; subsequent occurrences look up the same `EntityAclEntry` reference. The output list preserves duplicates in input order (contract), but DB load and permission probes do not. + +### Why per-entity, not role-level + +The 3-argument `hasPermission(user, resource, operation)` form asks "does this *authority* permit this op on this *resource type* in general?" — it doesn't take the entity. For `CUSTOMER_USER`, that returns `true` for `WRITE` on **any** `DEVICE`, because the role abstractly grants WRITE on devices. The actual per-instance check (`user.customerId == entity.customerId`) lives in the 5-argument `hasPermission(user, resource, op, entityId, entity)` form. To call it, we must first load the entity object — which is what step 2 above provides via `EntityServiceRegistry`. + +This is the core deviation from the original (role-level) design in issue #15496. + +### Size limit + +`enrichEntities.size() > maxAclEntities` → `ThingsboardException(BAD_REQUEST_PARAMS)` before any entity load. + +## 7. Component Layout (Variant A — inline in controller) + +### New files + +- `common/data/src/main/java/org/thingsboard/server/common/data/rule/engine/EnrichedRuleEngineRequest.java` + - Fields: `EntityId originator`, `String messageType`, `String queueName`, `Integer timeout`, `JsonNode payload`, `List enrichEntities` + - `@Data` + `@JsonIgnoreProperties(ignoreUnknown = true)` +- `common/data/src/main/java/org/thingsboard/server/common/data/rule/engine/EntityAclEntry.java` + - Fields: `EntityType entityType`, `UUID entityId`, `List allowed` + - `@Data @NoArgsConstructor @AllArgsConstructor` + - Flat shape (not `EntityId`) so JS/TBEL nodes can iterate naturally. + +### Modified files + +- `common/message/.../TbMsgMetaData.java` + - Add `public static final String TB_ACL_KEY = "tb_acl";` + - Add `public static final String TB_USER_ID_KEY = "tb_user_id";` +- `application/.../controller/RuleEngineController.java` + - `AccessControlService accessControlService` is already inherited from `BaseController`. + - Inject `EntityServiceRegistry entityServiceRegistry` — required to load arbitrary entities by `EntityType` (the per-entity `hasPermission` form needs the entity object, not just the id). + - Add `@Value("${server.rest.rule_engine.acl.max_entities:20}") int maxAclEntities;` + - Add a single `POST /api/rule-engine/v2/` endpoint method `handleEnrichedRuleEngineRequest(@RequestBody EnrichedRuleEngineRequest)`. + - Add private helpers `buildAclMetadata(SecurityUser, List)` and `computeEntry(SecurityUser, EntityId)`. The latter is the per-entity computation described in §6. + - Method-level `@SuppressWarnings("deprecation")` with an explanatory comment is required because `TbMsg.TbMsgBuilder.type(String)` is intentionally `@Deprecated` to gate accidental misuse — but our case (accepting an arbitrary `messageType` string from the request body) is exactly the use case the method is preserved for. + - The flow: parse `EnrichedRuleEngineRequest` → resolve `originator` (body or current user), `messageType` (body or `REST_API_REQUEST`), `queueName` (body or null), `timeout` (body or `defaultResponseTimeout`), `payload` (body or `{}`) → validate `enrichEntities.size() ≤ maxAclEntities` → existing `AccessValidator` WRITE check on originator → inside `onSuccess()`, compute ACL via `buildAclMetadata(...)` → populate metadata in the order `serviceId, requestUUID, expirationTime, tb_user_id, tb_acl` → build `TbMsg` with `data = payloadString` and the resolved `messageType` (via `type(String)`) → call `ruleEngineCallService.processRestApiCallToRuleEngine(...)` exactly as today. +- `application/src/main/resources/thingsboard.yml` + - Add the `acl.max_entities` property under the existing `server.rest.rule_engine` section (where `response_timeout` already lives). The original issue placed it under a top-level `rule-engine:` section, which does not exist in `thingsboard.yml`; co-locating it with `response_timeout` matches the local convention. + +### Zero impact on + +- v1 endpoints (`POST /api/rule-engine/...`) — untouched. +- `AccessValidator`, `AccessControlService`, `Resource`, `Operation` — consumed only. +- `RuleEngineCallService`, `TbClusterService` — unchanged; they receive a `TbMsg` that simply has extra metadata keys. +- Downstream rule nodes — consume or ignore the new metadata at their discretion. + +## 8. Flow Diagram + +``` + ┌──────┐ POST /api/rule-engine/v2/ + │ │ { originator, messageType, queueName, timeout, + │ User │ payload, enrichEntities:[{entityType,id},...] } + │ │─────────────────────────┐ + │ │ │ + └──────┘ ▼ + ┌──────────────────────┐ + │ RuleEngineController │ + │ /v2 endpoint │ + └───────┬──────────────┘ + │ 1. parse JSON → EnrichedRuleEngineRequest + │ (resolve defaults: originator, + │ messageType, timeout, queueName, + │ payload null/missing → "{}") + │ 2. validate size ≤ maxAclEntities + │ 3. AccessValidator WRITE on originator + │ (existing check, unchanged) + ▼ + ┌──────────────────────┐ + │ buildAclMetadata() │ + │ (inline helper) │ + └───────┬──────────────┘ + │ for each entity in input order: + │ cache.computeIfAbsent(id, → + │ Resource.of(entityType) + │ entityServiceRegistry + │ .getServiceByEntityType(t) + │ .findEntity(tenantId, id) + │ for each Operation: + │ hasPermission(user, res, op, + │ entityId, entity) + │ ) + │ serialize List + ▼ + ┌──────────────────────┐ + │ TbMsgMetaData │ + │ serviceId │ + │ requestUUID │ + │ expirationTime │ + │ tb_user_id ◀──────── SERVER + │ tb_acl ◀──────── OVERWRITE (always last) + └───────┬──────────────┘ + │ build TbMsg(data=payload, metaData=above) + ▼ + ┌──────────────────────┐ + │ RuleEngineCallService│ + │ (unchanged) │ + └───────┬──────────────┘ + │ push to queue + ▼ + ┌──────────────────────┐ + │ Rule Engine │ + │ rule chain │ + │ script/switch nodes│ + │ read metadata.tb_acl│ + │ read metadata.tb_user_id│ + │ decide: pass/block │ + │ → REST Call Reply │ + └───────┬──────────────┘ + │ response TbMsg + ▼ + ┌──────────────────────┐ + │ Response to User │ + │ (200 or whatever │ + │ the rule chain set)│ + └──────────────────────┘ +``` + +## 9. Rule Node Usage Examples + +> Entries use a flat `{entityType, entityId, allowed[]}` shape so JS/TBEL nodes don't +> need to know about the polymorphic `EntityId` form. +> +> `allowed` reflects the **per-entity** capability — operations the calling user can +> perform on that specific entity instance. Empty `allowed` means the user has no +> access on the entity (for any reason: no permissions, stale id, unsupported type). + +### JS script node — gate by per-entity capability + +```js +var acl = JSON.parse(metadata.tb_acl); + +var canWriteTargetDevice = acl.some(function(e) { + return e.entityType === 'DEVICE' + && e.entityId === msg.targetDeviceId + && e.allowed.indexOf('WRITE') >= 0; +}); + +var userId = metadata.tb_user_id; +metadata.auditActor = userId; + +return { msg: msg, metadata: metadata, msgType: canWriteTargetDevice ? 'Allowed' : 'Denied' }; +``` + +### TBEL script node + +``` +var acl = JSON.parse(metadata.tb_acl); +var canWriteTargetDevice = false; +foreach (e : acl) { + if (e.entityType == "DEVICE" && e.entityId == msg.targetDeviceId + && e.allowed.contains("WRITE")) { + canWriteTargetDevice = true; + break; + } +} +metadata.auditActor = metadata.tb_user_id; +return {msg: msg, metadata: metadata, msgType: canWriteTargetDevice ? "Allowed" : "Denied"}; +``` + +### Filter pattern (entities the caller can WRITE) + +```js +var writable = JSON.parse(metadata.tb_acl) + .filter(function(e) { return e.allowed.indexOf('WRITE') >= 0; }) + .map(function(e) { return e.entityType + ':' + e.entityId; }); +``` + +## 10. Error Handling + +| Condition | Response | +|---|---| +| Malformed JSON body | `400 Bad Request` (existing behavior) | +| `enrichEntities.size() > maxAclEntities` | `400 Bad Request` with message | +| Unknown `entityType` (`EntityIdDeserializer` fails) | `400 Bad Request` (existing behavior) | +| No WRITE on originator | `401 Unauthorized` (existing `AccessValidator`) | +| `EntityType` has no matching `Resource` | Entry with `allowed=[]`; WARN log; no HTTP error | +| `EntityType` has no `EntityDaoService` registered | Entry with `allowed=[]`; WARN log; no HTTP error | +| Entity not found / cross-tenant / not `HasTenantId` | Entry with `allowed=[]`; WARN log; no HTTP error | +| Operation inapplicable to role (throws) | Silently skipped from `allowed` — no error | +| Null/missing `payload` | Treated as empty JSON object `{}` — no error | +| Rule Engine processing timeout | `408 Request Timeout` (existing) | + +## 11. Security Considerations + +- **Immutable server fields.** `tb_acl` and `tb_user_id` are written by the controller after all other metadata population, so any caller-supplied value of the same key is overwritten. `TbMsgMetaData.putValue(...)` is replace semantics — no array/append path. +- **No privilege escalation.** The ACL snapshot reports *only* what the user already has on each specific entity. It can never grant more than the user can do directly. +- **Information disclosure.** The snapshot tells the rule chain (which the user cannot read directly) what the caller is allowed to do on each listed entity. Since the caller supplied the list and the data is about themselves, this is not a new information leak. +- **DoS vector.** Bounded by: + - up to `max-entities` entity loads from the DAO (≤20 SELECT statements per request — many DAOs have L2 cache, so repeated lookups within a session are essentially free); + - `max-entities × Operation.values().length` in-memory permission checks (≤20 × 18 = 360 checks worst case). + Dedup cache (§6) collapses repeated ids in a single request to one load + one check set. +- **Audit.** `tb_user_id` inside rule chains lets authors emit richer audit events that today's `REST_API_RULE_ENGINE_CALL` audit log does not propagate. + +## 12. Test Plan + +### Integration (controller) + +All tests in `RuleEngineControllerV2Test`, `@DaoSqlTest`, extending `AbstractControllerTest`, with `@SpyBean RuleEngineCallService` to capture the forwarded `TbMsg`. + +- Tenant admin + own DEVICE → `allowed` contains at least READ/WRITE/DELETE/WRITE_TELEMETRY; `tb_user_id` equals caller UUID. +- Customer user + DEVICE assigned to the user's customer → `allowed` contains the customer-grade operations (WRITE, READ_TELEMETRY, etc.). +- Customer user + DEVICE assigned to a DIFFERENT customer → `allowed` does not contain WRITE/READ/READ_TELEMETRY (per-entity check correctly denies). Note: the platform allows `CLAIM_DEVICES` on any tenant device by design — that is expected to be present. +- Two-customer cross-scenario: deviceA on customer1, deviceB on customer2; customer1 user sees WRITE on A and not on B; customer2 user sees the inverse. +- Empty or null `enrichEntities` → `tb_acl = "[]"`; `tb_user_id` still present. +- Duplicate ids in `enrichEntities` → output preserves order and multiplicity. +- `enrichEntities.size() > max` → HTTP 400. +- `RULE_NODE` (no `Resource` mapping) → entry with `allowed=[]`, no error. +- Nonexistent UUID for a valid `EntityType` → entry with `allowed=[]`, no error. +- Caller embeds `{"tb_acl": "attack", "tb_user_id": "intruder"}` in `payload` → forwarded metadata keeps server-computed values; the caller's keys never reach the rule engine. +- Body `messageType=...` and `timeout=...` → forwarded `TbMsg.type` and `expirationTime` reflect the body values. +- Null/missing `payload` → forwarded `TbMsg.data` is `"{}"`. +- v1 endpoints regression suite (`RuleEngineControllerTest`) continues to pass. + +## 13. Out of Scope / Future Work + +- **Extending enrichment to other controllers that push to the Rule Engine** (e.g., `TelemetryController`, `RpcController`). They have the same "message in RE has no user context" gap. Each would be its own additive v2 endpoint. If/when we need this in more than one controller, it would also motivate extracting `buildAclMetadata` / `computeEntry` into a shared `AclEnrichmentService` (Variant B from the original design — deferred until a real second caller exists). +- **Additional enriched fields** (e.g., `tb_user_email`, `tb_user_authority`) if rule-chain authors ask for them. Each is a small additive change. We avoid speculative addition — extra fields mean larger metadata and more invariants to keep stable across renames. + +## 14. Open Questions + +None at approval time. + +--- + +## 15. Manual Smoke-Test Recipe + +1. Start ThingsBoard locally (Postgres + core + rule-engine node). +2. Log in as Tenant Admin. Create a simple rule chain: + - **Script** node reading `metadata.tb_acl` and setting `msgType` to `Allowed` or `Denied`. + - **REST Call Reply** node. +3. Set this rule chain as root (or route via `queueName`). +4. Call: + + ```bash + curl -X POST http://localhost:8080/api/rule-engine/v2/ \ + -H "Content-Type: application/json" \ + -H "X-Authorization: Bearer $JWT" \ + -d '{ + "payload": {"k":"v"}, + "enrichEntities": [ + {"entityType":"DEVICE","id":""} + ] + }' + ``` + +5. Expected: HTTP 200 with body `{"msgType":"Allowed"}` (tenant admin) or `{"msgType":"Denied"}` (customer user without per-entity access). +6. In Rule Chain debug mode, inspect the incoming `TbMsg` — `metadata.tb_acl` and `metadata.tb_user_id` are present and authoritative. 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..eab44d591f 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; @@ -34,24 +35,34 @@ 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.EnrichedRuleEngineRequest; +import org.thingsboard.server.common.data.rule.engine.EntityAclEntry; 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.EntityServiceRegistry; 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.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; import java.util.UUID; import java.util.concurrent.TimeoutException; @@ -78,6 +89,11 @@ public class RuleEngineController extends BaseController { private RuleEngineCallService ruleEngineCallService; @Autowired private AccessValidator accessValidator; + @Autowired + private EntityServiceRegistry entityServiceRegistry; + + @Value("${server.rest.rule_engine.acl.max_entities:20}") + private int maxAclEntities; @ApiOperation(value = "Push user message to the rule engine (handleRuleEngineRequestForUser)", notes = MSG_DESCRIPTION_PREFIX + @@ -209,6 +225,132 @@ public class RuleEngineController extends BaseController { } } + @ApiOperation(value = "Push enriched message to the rule engine (handleEnrichedRuleEngineRequest)", + notes = MSG_DESCRIPTION_PREFIX + + "All routing parameters (originator, messageType, queueName, timeout) are passed in the request body. " + + "Optionally accepts an `enrichEntities` list. For each entity, the controller computes the set of " + + "operations the calling user is allowed to perform on that specific entity instance and writes the " + + "result as a JSON array under the protected `tb_acl` metadata key. The calling user's id is written " + + "under `tb_user_id`. Both metadata keys are server-authoritative — any value supplied via the payload " + + "is overwritten. " + + "The `payload` field is optional; a null or missing payload is treated as an empty JSON object `{}` " + + "so probe-only requests (callers who want only the ACL snapshot) work without a body. " + + MSG_DESCRIPTION + + "\n\n" + ControllerConstants.SECURITY_WRITE_CHECK) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/v2/", method = RequestMethod.POST) + @ResponseBody + // type(String) is the supported form for custom messageType supplied via the request body; + // the @Deprecated annotation on TbMsgBuilder.type(String) gates accidental misuse elsewhere. + @SuppressWarnings("deprecation") + public DeferredResult handleEnrichedRuleEngineRequest( + @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "Enriched rule engine request", required = true) + @RequestBody EnrichedRuleEngineRequest request) throws ThingsboardException { + SecurityUser currentUser = getCurrentUser(); + + List enrichEntities = request.getEnrichEntities() != null ? request.getEnrichEntities() : List.of(); + if (enrichEntities.size() > maxAclEntities) { + throw new ThingsboardException("enrichEntities exceeds the limit of " + maxAclEntities, + ThingsboardErrorCode.BAD_REQUEST_PARAMS); + } + + EntityId originator = request.getOriginator() != null ? request.getOriginator() : currentUser.getId(); + String messageType = request.getMessageType() != null ? request.getMessageType() : TbMsgType.REST_API_REQUEST.name(); + String queueName = request.getQueueName(); + int timeout = request.getTimeout() != null ? request.getTimeout() : defaultResponseTimeout; + JsonNode payload = request.getPayload(); + String payloadString = payload != null && !payload.isNull() ? JacksonUtil.toString(payload) : "{}"; + + DeferredResult response = new DeferredResult<>(); + accessValidator.validate(currentUser, Operation.WRITE, originator, new HttpValidationCallback(response, new FutureCallback>() { + @Override + public void onSuccess(@Nullable DeferredResult result) { + long expTime = System.currentTimeMillis() + timeout; + UUID requestId = UUID.randomUUID(); + HashMap metaData = new HashMap<>(); + metaData.put("serviceId", serviceInfoProvider.getServiceId()); + metaData.put("requestUUID", requestId.toString()); + metaData.put("expirationTime", Long.toString(expTime)); + // tb_user_id and tb_acl are written last so any caller-supplied value is overwritten. + metaData.put(TbMsgMetaData.TB_USER_ID_KEY, currentUser.getId().getId().toString()); + metaData.put(TbMsgMetaData.TB_ACL_KEY, buildAclMetadata(currentUser, enrichEntities)); + + TbMsg msg = TbMsg.newMsg() + .queueName(queueName) + .type(messageType) + .originator(originator) + .customerId(currentUser.getCustomerId()) + .copyMetaData(new TbMsgMetaData(metaData)) + .data(payloadString) + .build(); + ruleEngineCallService.processRestApiCallToRuleEngine(currentUser.getTenantId(), requestId, msg, queueName != null, + reply -> reply(new LocalRequestMetaData(msg, currentUser, result), reply)); + } + + @Override + public void onFailure(Throwable e) { + ResponseEntity entity; + if (e instanceof ToErrorResponseEntity) { + entity = ((ToErrorResponseEntity) e).toErrorResponseEntity(); + } else { + entity = new ResponseEntity(HttpStatus.UNAUTHORIZED); + } + logRuleEngineCall(currentUser, originator, payloadString, null, e); + response.setResult(entity); + } + })); + return response; + } + + String buildAclMetadata(SecurityUser user, List enrichEntities) { + Map cache = new HashMap<>(); + List result = new ArrayList<>(enrichEntities.size()); + for (EntityId id : enrichEntities) { + EntityAclEntry entry = cache.computeIfAbsent(id, eid -> computeEntry(user, eid)); + result.add(entry); + } + return JacksonUtil.toString(result); + } + + private EntityAclEntry computeEntry(SecurityUser user, EntityId entityId) { + Resource resource; + try { + resource = Resource.of(entityId.getEntityType()); + } catch (IllegalArgumentException e) { + log.warn("[{}] tb_acl: no Resource mapping for EntityType {} (entity {}); returning empty allowed", + user.getTenantId(), entityId.getEntityType(), entityId.getId()); + return new EntityAclEntry(entityId.getEntityType(), entityId.getId(), List.of()); + } + + Optional> entityOpt; + try { + entityOpt = entityServiceRegistry + .getServiceByEntityType(entityId.getEntityType()) + .findEntity(user.getTenantId(), entityId); + } catch (IllegalArgumentException e) { + log.warn("[{}] tb_acl: no EntityDaoService for EntityType {} (entity {}); returning empty allowed", + user.getTenantId(), entityId.getEntityType(), entityId.getId()); + return new EntityAclEntry(entityId.getEntityType(), entityId.getId(), List.of()); + } + if (entityOpt.isEmpty() || !(entityOpt.get() instanceof HasTenantId entity)) { + log.warn("[{}] tb_acl: entity {} {} not found (stale id, cross-tenant, or system-level); returning empty allowed", + user.getTenantId(), entityId.getEntityType(), entityId.getId()); + return new EntityAclEntry(entityId.getEntityType(), entityId.getId(), List.of()); + } + + List allowed = new ArrayList<>(); + for (Operation op : Operation.values()) { + try { + if (accessControlService.hasPermission(user, resource, op, entityId, entity)) { + allowed.add(op.name()); + } + } catch (ThingsboardException ignored) { + // role has no checker for this resource — skip. + } + } + return new EntityAclEntry(entityId.getEntityType(), entityId.getId(), 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..3dc809d168 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -115,6 +115,9 @@ server: rule_engine: # Default timeout for waiting response of REST API request to Rule Engine in milliseconds response_timeout: "${DEFAULT_RULE_ENGINE_RESPONSE_TIMEOUT:10000}" + acl: + # Maximum number of entities accepted in /api/rule-engine/v2 enrichEntities. Bounds DB load and permission checks per request. + max_entities: "${RULE_ENGINE_ACL_MAX_ENTITIES:20}" # Application info parameters app: diff --git a/application/src/test/java/org/thingsboard/server/controller/RuleEngineControllerV2Test.java b/application/src/test/java/org/thingsboard/server/controller/RuleEngineControllerV2Test.java new file mode 100644 index 0000000000..94ea80d721 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/RuleEngineControllerV2Test.java @@ -0,0 +1,318 @@ +/** + * 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.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.EnrichedRuleEngineRequest; +import org.thingsboard.server.common.data.rule.engine.EntityAclEntry; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.TbMsgMetaData; +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.verify; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@DaoSqlTest +public class RuleEngineControllerV2Test extends AbstractControllerTest { + + private static final String URL = "/api/rule-engine/v2/"; + private static final String RESPONSE_BODY = "{\"response\":\"ok\"}"; + + @SpyBean + private RuleEngineCallService ruleEngineCallService; + + @Test + public void testV2TenantAdminGetsFullAclOnOwnDevice() throws Exception { + loginTenantAdmin(); + Device device = createDevice("dev-tenant", "tok-1"); + + EnrichedRuleEngineRequest request = baseRequest(); + request.setEnrichEntities(List.of(device.getId())); + + TbMsg captured = doRequestAndCapture(request, tenantId); + + assertThat(captured.getMetaData().getValue(TbMsgMetaData.TB_USER_ID_KEY)) + .isEqualTo(tenantAdminUserId.getId().toString()); + List acl = parseAcl(captured); + assertThat(acl).hasSize(1); + assertThat(acl.get(0).getEntityId()).isEqualTo(device.getId().getId()); + assertThat(acl.get(0).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(); + + EnrichedRuleEngineRequest request = baseRequest(); + request.setEnrichEntities(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(); + + EnrichedRuleEngineRequest request = baseRequest(); + request.setEnrichEntities(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().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(); + EnrichedRuleEngineRequest req1 = baseRequest(); + req1.setEnrichEntities(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().getId()); + assertThat(acl1.get(0).getAllowed()).contains("WRITE", "READ_TELEMETRY"); + assertThat(acl1.get(1).getEntityId()).isEqualTo(deviceB.getId().getId()); + assertThat(acl1.get(1).getAllowed()).doesNotContain("WRITE", "READ", "READ_TELEMETRY"); + + // Customer 2 (foreign A, own B). loginDifferentCustomer() helper has a bug on its + // second invocation (uses the wrong password constant), so we log in directly. + login(DIFFERENT_CUSTOMER_USER_EMAIL, "diffcustomer"); + EnrichedRuleEngineRequest req2 = baseRequest(); + req2.setEnrichEntities(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().getId()); + assertThat(acl2.get(0).getAllowed()).doesNotContain("WRITE", "READ", "READ_TELEMETRY"); + assertThat(acl2.get(1).getEntityId()).isEqualTo(deviceB.getId().getId()); + assertThat(acl2.get(1).getAllowed()).contains("WRITE", "READ_TELEMETRY"); + } + + @Test + public void testV2EmptyEnrichEntitiesProducesEmptyAcl() throws Exception { + loginTenantAdmin(); + + EnrichedRuleEngineRequest request = baseRequest(); + // enrichEntities left null + TbMsg captured = doRequestAndCapture(request, tenantId); + + assertThat(captured.getMetaData().getValue(TbMsgMetaData.TB_ACL_KEY)).isEqualTo("[]"); + assertThat(captured.getMetaData().getValue(TbMsgMetaData.TB_USER_ID_KEY)) + .isEqualTo(tenantAdminUserId.getId().toString()); + } + + @Test + public void testV2DuplicateEntitiesPreservedInOutput() throws Exception { + loginTenantAdmin(); + Device device = createDevice("dev-dup", "tok-dup"); + Device other = createDevice("dev-other", "tok-other"); + + EnrichedRuleEngineRequest request = baseRequest(); + request.setEnrichEntities(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().getId()); + assertThat(acl.get(1).getEntityId()).isEqualTo(device.getId().getId()); + assertThat(acl.get(2).getEntityId()).isEqualTo(other.getId().getId()); + } + + @Test + public void testV2RejectsRequestExceedingMaxEntities() throws Exception { + loginTenantAdmin(); + List tooMany = new ArrayList<>(); + for (int i = 0; i < 21; i++) { + tooMany.add(new DeviceId(UUID.randomUUID())); + } + + EnrichedRuleEngineRequest request = baseRequest(); + request.setEnrichEntities(tooMany); + + doPost(URL, request).andExpect(status().isBadRequest()); + } + + @Test + public void testV2UnmappedEntityTypeProducesEmptyAcl() throws Exception { + loginTenantAdmin(); + // RULE_NODE has no Resource mapping — Resource.of throws, allowed=[]. + RuleNodeId fakeRuleNode = new RuleNodeId(UUID.randomUUID()); + + EnrichedRuleEngineRequest request = baseRequest(); + request.setEnrichEntities(List.of(fakeRuleNode)); + + TbMsg captured = doRequestAndCapture(request, tenantId); + + List acl = parseAcl(captured); + assertThat(acl).hasSize(1); + assertThat(acl.get(0).getEntityType()).isEqualTo(EntityType.RULE_NODE); + assertThat(acl.get(0).getAllowed()).isEmpty(); + } + + @Test + public void testV2NonexistentDeviceProducesEmptyAcl() throws Exception { + loginTenantAdmin(); + DeviceId ghost = new DeviceId(UUID.randomUUID()); + + EnrichedRuleEngineRequest request = baseRequest(); + request.setEnrichEntities(List.of(ghost)); + + TbMsg captured = doRequestAndCapture(request, tenantId); + + List acl = parseAcl(captured); + assertThat(acl).hasSize(1); + assertThat(acl.get(0).getEntityId()).isEqualTo(ghost.getId()); + assertThat(acl.get(0).getAllowed()).isEmpty(); + } + + @Test + public void testV2PayloadCannotInjectAclMetadata() throws Exception { + loginTenantAdmin(); + Device device = createDevice("dev-inj", "tok-inj"); + + EnrichedRuleEngineRequest request = baseRequest(); + request.setPayload(JacksonUtil.toJsonNode("{\"" + TbMsgMetaData.TB_ACL_KEY + "\":\"attack\",\"" + + TbMsgMetaData.TB_USER_ID_KEY + "\":\"intruder\"}")); + request.setEnrichEntities(List.of(device.getId())); + + TbMsg captured = doRequestAndCapture(request, tenantId); + + // Server-computed values, not the attacker's. + assertThat(captured.getMetaData().getValue(TbMsgMetaData.TB_ACL_KEY)).contains("\"entityType\":\"DEVICE\""); + assertThat(captured.getMetaData().getValue(TbMsgMetaData.TB_USER_ID_KEY)) + .isEqualTo(tenantAdminUserId.getId().toString()); + } + + @Test + public void testV2NullPayloadBecomesEmptyJsonObject() throws Exception { + loginTenantAdmin(); + EnrichedRuleEngineRequest request = new EnrichedRuleEngineRequest(); + // payload deliberately not set — exercises the documented null/missing → "{}" path. + + TbMsg captured = doRequestAndCapture(request, tenantId); + + assertThat(captured.getData()).isEqualTo("{}"); + } + + @Test + public void testV2HonorsBodyMessageTypeAndTimeout() throws Exception { + loginTenantAdmin(); + + EnrichedRuleEngineRequest request = baseRequest(); + request.setMessageType("MY_CUSTOM_TYPE"); + request.setTimeout(2000); + + long beforeMs = System.currentTimeMillis(); + TbMsg captured = doRequestAndCapture(request, tenantId); + long afterMs = System.currentTimeMillis(); + + assertThat(captured.getType()).isEqualTo("MY_CUSTOM_TYPE"); + long expirationTime = Long.parseLong(captured.getMetaData().getValue("expirationTime")); + assertThat(expirationTime).isBetween(beforeMs + 2000, afterMs + 2000); + } + + private EnrichedRuleEngineRequest baseRequest() { + EnrichedRuleEngineRequest request = new EnrichedRuleEngineRequest(); + request.setPayload(JacksonUtil.toJsonNode("{\"k\":\"v\"}")); + return request; + } + + private TbMsg doRequestAndCapture(EnrichedRuleEngineRequest 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.TB_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/EnrichedRuleEngineRequest.java b/common/data/src/main/java/org/thingsboard/server/common/data/rule/engine/EnrichedRuleEngineRequest.java new file mode 100644 index 0000000000..5d583128bd --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/rule/engine/EnrichedRuleEngineRequest.java @@ -0,0 +1,36 @@ +/** + * 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.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.Data; +import org.thingsboard.server.common.data.id.EntityId; + +import java.util.List; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class EnrichedRuleEngineRequest { + + private EntityId originator; + private String messageType; + private String queueName; + private Integer timeout; + private JsonNode payload; + private List enrichEntities; + +} 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..59f7fe9006 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/rule/engine/EntityAclEntry.java @@ -0,0 +1,35 @@ +/** + * 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 lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.EntityType; + +import java.util.List; +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class EntityAclEntry { + + private EntityType entityType; + private UUID entityId; + private List allowed; + +} 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..7884dfb763 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,9 @@ public final class TbMsgMetaData implements Serializable { public static final TbMsgMetaData EMPTY = new TbMsgMetaData(0); + public static final String TB_ACL_KEY = "tb_acl"; + public static final String TB_USER_ID_KEY = "tb_user_id"; + private final Map data; public TbMsgMetaData() { From 6f615694edc1e0e369b487e5d25b348d8708d490 Mon Sep 17 00:00:00 2001 From: Oleksandra Matviienko Date: Tue, 12 May 2026 13:38:20 +0200 Subject: [PATCH 02/10] Refined /api/rule-engine/v2 endpoint API and naming - Removed optional messageType from EnrichedRuleEngineRequest; TbMsg.type is hardcoded to REST_API_REQUEST, matching v1 - Renamed reserved metadata keys to camelCase with tb_ namespace: tb_aclSnapshot and tb_userId (was tb_acl, tb_user_id) - Replaced EntityServiceRegistry chain with EntityService.fetchEntity - Dropped trailing slash from /v2 mapping for Spring 6 strict matching - Added @Schema annotations on EnrichedRuleEngineRequest and EntityAclEntry fields - Documented SYS_ADMIN no-op behavior in V2 endpoint description - Clarified server.rest.rule_engine.acl.max_entities=0 semantics in yml --- ...roller-acl-enrichment-per-entity-design.md | 76 +++++++++---------- .../controller/RuleEngineController.java | 37 +++++---- .../src/main/resources/thingsboard.yml | 2 + .../RuleEngineControllerV2Test.java | 7 +- .../engine/EnrichedRuleEngineRequest.java | 18 ++++- .../data/rule/engine/EntityAclEntry.java | 14 ++++ .../server/common/msg/TbMsgMetaData.java | 16 +++- 7 files changed, 105 insertions(+), 65 deletions(-) diff --git a/RuleEngineController-acl-enrichment-per-entity-design.md b/RuleEngineController-acl-enrichment-per-entity-design.md index 0222398a9e..593145b97a 100644 --- a/RuleEngineController-acl-enrichment-per-entity-design.md +++ b/RuleEngineController-acl-enrichment-per-entity-design.md @@ -29,7 +29,7 @@ Rule chains are free to ignore the metadata entirely — existing flows are unaf ## 3. Non-Goals - Denying the request at the API layer based on the ACL snapshot. Decisions live in the rule chain. -- Modifying or replacing the existing `POST /api/rule-engine/...` v1 endpoints. This adds new `/v2/` endpoint alongside them. +- Modifying or replacing the existing `POST /api/rule-engine/...` v1 endpoints. This adds new `/v2` endpoint alongside them. - Changing `AccessValidator`, `AccessControlService`, `Resource`, `Operation`, or permission-model semantics. We consume them as-is. - UI changes. - Extending enrichment to controllers other than `RuleEngineController` (see §13). @@ -38,7 +38,7 @@ Rule chains are free to ignore the metadata entirely — existing flows are unaf A single new endpoint with all routing parameters in the request body: -- `POST /api/rule-engine/v2/` +- `POST /api/rule-engine/v2` Same authorization as v1: `hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')` and existing `AccessValidator` WRITE check on the originator. @@ -47,7 +47,6 @@ Same authorization as v1: `hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOME ```json { "originator": { "entityType": "DEVICE", "id": "784f394c-42b6-..." }, - "messageType": "REST_API_REQUEST", "queueName": "Main", "timeout": 10000, "payload": { "any": "user json" }, @@ -59,11 +58,11 @@ Same authorization as v1: `hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOME ``` - `originator` — optional `EntityId`. If absent, defaults to the calling user's id (matches v1 behavior when path variables are omitted). -- `messageType` — optional. Defaults to `REST_API_REQUEST` (matches v1's hardcoded type — and what the platform documentation references as the input type for `rest call reply`, see https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/rest-call-reply/). Custom strings are accepted. +- The `TbMsg` type is hardcoded to `REST_API_REQUEST` (matches v1 across all four ruleEngine endpoints; this is the documented input type for the terminal `rest call reply` rule node, see https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/rest-call-reply/). - `queueName` — optional. When present, overrides the queue selected by the originator's profile (same semantics as v1). - `timeout` — optional, milliseconds. Defaults to `server.rest.rule_engine.response_timeout` (10s). - `payload` — optional JSON. A null or missing payload is treated as the empty JSON object `{}` so probe-only requests (callers who want only the ACL snapshot) work without a body. -- `enrichEntities` — optional list of `EntityId` (uses the platform's existing polymorphic `EntityId` Jackson representation: `{entityType, id}`). If absent or empty, `tb_acl` is `"[]"`. +- `enrichEntities` — optional list of `EntityId` (uses the platform's existing polymorphic `EntityId` Jackson representation: `{entityType, id}`). If absent or empty, `tb_aclSnapshot` is `"[]"`. - Size limit: configurable via `server.rest.rule_engine.acl.max_entities` (default **20**). Exceeding returns `400 Bad Request`. ### Response @@ -74,9 +73,9 @@ Unchanged from v1: `DeferredResult` populated by the Rule Engine Two new keys, both **always written last** when building `TbMsgMetaData`, so any user-supplied value (if a caller tried to sneak them in via the payload or elsewhere) is unconditionally overwritten. -### 5.1 `tb_acl` +### 5.1 `tb_aclSnapshot` -- Key constant: `TbMsgMetaData.TB_ACL_KEY = "tb_acl"` +- Key constant: `TbMsgMetaData.TB_ACL_KEY = "tb_aclSnapshot"` - Value: JSON string (serialized `List`). - Order: matches the order of `enrichEntities` in the request (deterministic). - Duplicate `EntityId` values in `enrichEntities` produce duplicate entries in the output in the same positions; the underlying entity is loaded only once (dedup cache per request). @@ -101,9 +100,9 @@ Two new keys, both **always written last** when building `TbMsgMetaData`, so any - An empty `allowed` list means either: (a) the user has no operations on this entity, (b) the entity was not found / cross-tenant / not `HasTenantId`, or (c) the `EntityType` has no `Resource` mapping. Server-side WARN logs distinguish (b) and (c) for operations debugging; rule-chain authors can treat empty as a single "no access" signal. - Format deliberately an array of flat objects (no compound keys like `"DEVICE:uuid"`) so it iterates naturally in JS and TBEL rule nodes. -### 5.2 `tb_user_id` +### 5.2 `tb_userId` -- Key constant: `TbMsgMetaData.TB_USER_ID_KEY = "tb_user_id"` +- Key constant: `TbMsgMetaData.TB_USER_ID_KEY = "tb_userId"` - Value: UUID string of the calling user (`currentUser.getId().getId().toString()`). - Stable across rename/email changes. - `tenantId` and `customerId` are already recoverable from `TbMsg` (originator, customerId), so they are not duplicated here. @@ -144,7 +143,7 @@ This is the core deviation from the original (role-level) design in issue #15496 ### New files - `common/data/src/main/java/org/thingsboard/server/common/data/rule/engine/EnrichedRuleEngineRequest.java` - - Fields: `EntityId originator`, `String messageType`, `String queueName`, `Integer timeout`, `JsonNode payload`, `List enrichEntities` + - Fields: `EntityId originator`, `String queueName`, `Integer timeout`, `JsonNode payload`, `List enrichEntities` - `@Data` + `@JsonIgnoreProperties(ignoreUnknown = true)` - `common/data/src/main/java/org/thingsboard/server/common/data/rule/engine/EntityAclEntry.java` - Fields: `EntityType entityType`, `UUID entityId`, `List allowed` @@ -154,16 +153,16 @@ This is the core deviation from the original (role-level) design in issue #15496 ### Modified files - `common/message/.../TbMsgMetaData.java` - - Add `public static final String TB_ACL_KEY = "tb_acl";` - - Add `public static final String TB_USER_ID_KEY = "tb_user_id";` + - Add `public static final String TB_ACL_KEY = "tb_aclSnapshot";` + - Add `public static final String TB_USER_ID_KEY = "tb_userId";` - `application/.../controller/RuleEngineController.java` - `AccessControlService accessControlService` is already inherited from `BaseController`. - - Inject `EntityServiceRegistry entityServiceRegistry` — required to load arbitrary entities by `EntityType` (the per-entity `hasPermission` form needs the entity object, not just the id). + - Inject `EntityService entityService` — required to load the target entity (the per-entity `hasPermission` form needs the entity object, not just the id). The internal call `entityService.fetchEntity(tenantId, entityId)` dispatches via `EntityServiceRegistry` and returns `Optional>`. - Add `@Value("${server.rest.rule_engine.acl.max_entities:20}") int maxAclEntities;` - - Add a single `POST /api/rule-engine/v2/` endpoint method `handleEnrichedRuleEngineRequest(@RequestBody EnrichedRuleEngineRequest)`. + - Add a single `POST /api/rule-engine/v2` endpoint method `handleEnrichedRuleEngineRequest(@RequestBody EnrichedRuleEngineRequest)`. - Add private helpers `buildAclMetadata(SecurityUser, List)` and `computeEntry(SecurityUser, EntityId)`. The latter is the per-entity computation described in §6. - - Method-level `@SuppressWarnings("deprecation")` with an explanatory comment is required because `TbMsg.TbMsgBuilder.type(String)` is intentionally `@Deprecated` to gate accidental misuse — but our case (accepting an arbitrary `messageType` string from the request body) is exactly the use case the method is preserved for. - - The flow: parse `EnrichedRuleEngineRequest` → resolve `originator` (body or current user), `messageType` (body or `REST_API_REQUEST`), `queueName` (body or null), `timeout` (body or `defaultResponseTimeout`), `payload` (body or `{}`) → validate `enrichEntities.size() ≤ maxAclEntities` → existing `AccessValidator` WRITE check on originator → inside `onSuccess()`, compute ACL via `buildAclMetadata(...)` → populate metadata in the order `serviceId, requestUUID, expirationTime, tb_user_id, tb_acl` → build `TbMsg` with `data = payloadString` and the resolved `messageType` (via `type(String)`) → call `ruleEngineCallService.processRestApiCallToRuleEngine(...)` exactly as today. + - The forwarded `TbMsg` type is hardcoded to `TbMsgType.REST_API_REQUEST` — matches v1 behavior across all four ruleEngine endpoints. No custom `messageType` input from the body, so the deprecated `TbMsg.TbMsgBuilder.type(String)` form is not needed. + - The flow: parse `EnrichedRuleEngineRequest` → resolve `originator` (body or current user), `queueName` (body or null), `timeout` (body or `defaultResponseTimeout`), `payload` (body or `{}`) → validate `enrichEntities.size() ≤ maxAclEntities` → existing `AccessValidator` WRITE check on originator → inside `onSuccess()`, compute ACL via `buildAclMetadata(...)` → populate metadata in the order `serviceId, requestUUID, expirationTime, tb_userId, tb_aclSnapshot` → build `TbMsg` with `type = TbMsgType.REST_API_REQUEST` and `data = payloadString` → call `ruleEngineCallService.processRestApiCallToRuleEngine(...)` exactly as today. - `application/src/main/resources/thingsboard.yml` - Add the `acl.max_entities` property under the existing `server.rest.rule_engine` section (where `response_timeout` already lives). The original issue placed it under a top-level `rule-engine:` section, which does not exist in `thingsboard.yml`; co-locating it with `response_timeout` matches the local convention. @@ -177,8 +176,8 @@ This is the core deviation from the original (role-level) design in issue #15496 ## 8. Flow Diagram ``` - ┌──────┐ POST /api/rule-engine/v2/ - │ │ { originator, messageType, queueName, timeout, + ┌──────┐ POST /api/rule-engine/v2 + │ │ { originator, queueName, timeout, │ User │ payload, enrichEntities:[{entityType,id},...] } │ │─────────────────────────┐ │ │ │ @@ -189,7 +188,7 @@ This is the core deviation from the original (role-level) design in issue #15496 └───────┬──────────────┘ │ 1. parse JSON → EnrichedRuleEngineRequest │ (resolve defaults: originator, - │ messageType, timeout, queueName, + │ timeout, queueName, │ payload null/missing → "{}") │ 2. validate size ≤ maxAclEntities │ 3. AccessValidator WRITE on originator @@ -216,8 +215,8 @@ This is the core deviation from the original (role-level) design in issue #15496 │ serviceId │ │ requestUUID │ │ expirationTime │ - │ tb_user_id ◀──────── SERVER - │ tb_acl ◀──────── OVERWRITE (always last) + │ tb_userId ◀──────── SERVER + │ tb_aclSnapshot ◀──────── OVERWRITE (always last) └───────┬──────────────┘ │ build TbMsg(data=payload, metaData=above) ▼ @@ -231,8 +230,8 @@ This is the core deviation from the original (role-level) design in issue #15496 │ Rule Engine │ │ rule chain │ │ script/switch nodes│ - │ read metadata.tb_acl│ - │ read metadata.tb_user_id│ + │ read metadata.tb_aclSnapshot│ + │ read metadata.tb_userId│ │ decide: pass/block │ │ → REST Call Reply │ └───────┬──────────────┘ @@ -257,7 +256,7 @@ This is the core deviation from the original (role-level) design in issue #15496 ### JS script node — gate by per-entity capability ```js -var acl = JSON.parse(metadata.tb_acl); +var acl = JSON.parse(metadata.tb_aclSnapshot); var canWriteTargetDevice = acl.some(function(e) { return e.entityType === 'DEVICE' @@ -265,7 +264,7 @@ var canWriteTargetDevice = acl.some(function(e) { && e.allowed.indexOf('WRITE') >= 0; }); -var userId = metadata.tb_user_id; +var userId = metadata.tb_userId; metadata.auditActor = userId; return { msg: msg, metadata: metadata, msgType: canWriteTargetDevice ? 'Allowed' : 'Denied' }; @@ -274,7 +273,7 @@ return { msg: msg, metadata: metadata, msgType: canWriteTargetDevice ? 'Allowed' ### TBEL script node ``` -var acl = JSON.parse(metadata.tb_acl); +var acl = JSON.parse(metadata.tb_aclSnapshot); var canWriteTargetDevice = false; foreach (e : acl) { if (e.entityType == "DEVICE" && e.entityId == msg.targetDeviceId @@ -283,14 +282,14 @@ foreach (e : acl) { break; } } -metadata.auditActor = metadata.tb_user_id; +metadata.auditActor = metadata.tb_userId; return {msg: msg, metadata: metadata, msgType: canWriteTargetDevice ? "Allowed" : "Denied"}; ``` ### Filter pattern (entities the caller can WRITE) ```js -var writable = JSON.parse(metadata.tb_acl) +var writable = JSON.parse(metadata.tb_aclSnapshot) .filter(function(e) { return e.allowed.indexOf('WRITE') >= 0; }) .map(function(e) { return e.entityType + ':' + e.entityId; }); ``` @@ -312,14 +311,14 @@ var writable = JSON.parse(metadata.tb_acl) ## 11. Security Considerations -- **Immutable server fields.** `tb_acl` and `tb_user_id` are written by the controller after all other metadata population, so any caller-supplied value of the same key is overwritten. `TbMsgMetaData.putValue(...)` is replace semantics — no array/append path. +- **Immutable server fields.** `tb_aclSnapshot` and `tb_userId` are written by the controller after all other metadata population, so any caller-supplied value of the same key is overwritten. `TbMsgMetaData.putValue(...)` is replace semantics — no array/append path. - **No privilege escalation.** The ACL snapshot reports *only* what the user already has on each specific entity. It can never grant more than the user can do directly. -- **Information disclosure.** The snapshot tells the rule chain (which the user cannot read directly) what the caller is allowed to do on each listed entity. Since the caller supplied the list and the data is about themselves, this is not a new information leak. +- **Information disclosure.** The snapshot tells the rule chain (which the user cannot read directly) what the caller is allowed to do on each listed entity. Since the caller supplied the list and the data is about themselves, this is not a new information leak. The response shape for an entity the caller cannot READ is **indistinguishable** from the shape for a nonexistent entity — both produce `{entityType, entityId, allowed: []}` — so the endpoint cannot be used as an existence probe for tenant-scoped entities outside the caller's access scope. - **DoS vector.** Bounded by: - up to `max-entities` entity loads from the DAO (≤20 SELECT statements per request — many DAOs have L2 cache, so repeated lookups within a session are essentially free); - `max-entities × Operation.values().length` in-memory permission checks (≤20 × 18 = 360 checks worst case). Dedup cache (§6) collapses repeated ids in a single request to one load + one check set. -- **Audit.** `tb_user_id` inside rule chains lets authors emit richer audit events that today's `REST_API_RULE_ENGINE_CALL` audit log does not propagate. +- **Audit.** `tb_userId` inside rule chains lets authors emit richer audit events that today's `REST_API_RULE_ENGINE_CALL` audit log does not propagate. ## 12. Test Plan @@ -327,17 +326,17 @@ var writable = JSON.parse(metadata.tb_acl) All tests in `RuleEngineControllerV2Test`, `@DaoSqlTest`, extending `AbstractControllerTest`, with `@SpyBean RuleEngineCallService` to capture the forwarded `TbMsg`. -- Tenant admin + own DEVICE → `allowed` contains at least READ/WRITE/DELETE/WRITE_TELEMETRY; `tb_user_id` equals caller UUID. +- Tenant admin + own DEVICE → `allowed` contains at least READ/WRITE/DELETE/WRITE_TELEMETRY; `tb_userId` equals caller UUID. - Customer user + DEVICE assigned to the user's customer → `allowed` contains the customer-grade operations (WRITE, READ_TELEMETRY, etc.). - Customer user + DEVICE assigned to a DIFFERENT customer → `allowed` does not contain WRITE/READ/READ_TELEMETRY (per-entity check correctly denies). Note: the platform allows `CLAIM_DEVICES` on any tenant device by design — that is expected to be present. - Two-customer cross-scenario: deviceA on customer1, deviceB on customer2; customer1 user sees WRITE on A and not on B; customer2 user sees the inverse. -- Empty or null `enrichEntities` → `tb_acl = "[]"`; `tb_user_id` still present. +- Empty or null `enrichEntities` → `tb_aclSnapshot = "[]"`; `tb_userId` still present. - Duplicate ids in `enrichEntities` → output preserves order and multiplicity. - `enrichEntities.size() > max` → HTTP 400. - `RULE_NODE` (no `Resource` mapping) → entry with `allowed=[]`, no error. - Nonexistent UUID for a valid `EntityType` → entry with `allowed=[]`, no error. -- Caller embeds `{"tb_acl": "attack", "tb_user_id": "intruder"}` in `payload` → forwarded metadata keeps server-computed values; the caller's keys never reach the rule engine. -- Body `messageType=...` and `timeout=...` → forwarded `TbMsg.type` and `expirationTime` reflect the body values. +- Caller embeds `{"tb_aclSnapshot": "attack", "tb_userId": "intruder"}` in `payload` → forwarded metadata keeps server-computed values; the caller's keys never reach the rule engine. +- Body `timeout=...` → forwarded `expirationTime` reflects the body value; `TbMsg.type` is always `REST_API_REQUEST` (matches v1). - Null/missing `payload` → forwarded `TbMsg.data` is `"{}"`. - v1 endpoints regression suite (`RuleEngineControllerTest`) continues to pass. @@ -345,6 +344,7 @@ All tests in `RuleEngineControllerV2Test`, `@DaoSqlTest`, extending `AbstractCon - **Extending enrichment to other controllers that push to the Rule Engine** (e.g., `TelemetryController`, `RpcController`). They have the same "message in RE has no user context" gap. Each would be its own additive v2 endpoint. If/when we need this in more than one controller, it would also motivate extracting `buildAclMetadata` / `computeEntry` into a shared `AclEnrichmentService` (Variant B from the original design — deferred until a real second caller exists). - **Additional enriched fields** (e.g., `tb_user_email`, `tb_user_authority`) if rule-chain authors ask for them. Each is a small additive change. We avoid speculative addition — extra fields mean larger metadata and more invariants to keep stable across renames. +- **Batched / parallel entity load.** `buildAclMetadata` currently calls `entityService.fetchEntity(...)` sequentially on the validation-callback thread. With `max-entities=20` the worst case is 20 sequential DAO reads before the message is forwarded; many entity DAOs use L2 cache so warm-path cost is much lower. A batch API returning the `TenantEntity`-typed objects required by the per-entity `hasPermission` form (today's `fetchEntityInfos` returns `EntityInfo` only, which is insufficient) would let us collapse this to a single round trip. Deferred until the bound becomes a measured problem. ## 14. Open Questions @@ -356,13 +356,13 @@ None at approval time. 1. Start ThingsBoard locally (Postgres + core + rule-engine node). 2. Log in as Tenant Admin. Create a simple rule chain: - - **Script** node reading `metadata.tb_acl` and setting `msgType` to `Allowed` or `Denied`. + - **Script** node reading `metadata.tb_aclSnapshot` and setting `msgType` to `Allowed` or `Denied`. - **REST Call Reply** node. 3. Set this rule chain as root (or route via `queueName`). 4. Call: ```bash - curl -X POST http://localhost:8080/api/rule-engine/v2/ \ + curl -X POST http://localhost:8080/api/rule-engine/v2 \ -H "Content-Type: application/json" \ -H "X-Authorization: Bearer $JWT" \ -d '{ @@ -374,4 +374,4 @@ None at approval time. ``` 5. Expected: HTTP 200 with body `{"msgType":"Allowed"}` (tenant admin) or `{"msgType":"Denied"}` (customer user without per-entity access). -6. In Rule Chain debug mode, inspect the incoming `TbMsg` — `metadata.tb_acl` and `metadata.tb_user_id` are present and authoritative. +6. In Rule Chain debug mode, inspect the incoming `TbMsg` — `metadata.tb_aclSnapshot` and `metadata.tb_userId` are present and authoritative. 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 eab44d591f..5a020b3796 100644 --- a/application/src/main/java/org/thingsboard/server/controller/RuleEngineController.java +++ b/application/src/main/java/org/thingsboard/server/controller/RuleEngineController.java @@ -49,7 +49,7 @@ import org.thingsboard.server.common.data.rule.engine.EntityAclEntry; 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.EntityServiceRegistry; +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; @@ -90,7 +90,7 @@ public class RuleEngineController extends BaseController { @Autowired private AccessValidator accessValidator; @Autowired - private EntityServiceRegistry entityServiceRegistry; + private EntityService entityService; @Value("${server.rest.rule_engine.acl.max_entities:20}") private int maxAclEntities; @@ -227,22 +227,22 @@ public class RuleEngineController extends BaseController { @ApiOperation(value = "Push enriched message to the rule engine (handleEnrichedRuleEngineRequest)", notes = MSG_DESCRIPTION_PREFIX + - "All routing parameters (originator, messageType, queueName, timeout) are passed in the request body. " + + "All routing parameters (originator, queueName, timeout) are passed in the request body. " + "Optionally accepts an `enrichEntities` list. For each entity, the controller computes the set of " + "operations the calling user is allowed to perform on that specific entity instance and writes the " + - "result as a JSON array under the protected `tb_acl` metadata key. The calling user's id is written " + - "under `tb_user_id`. Both metadata keys are server-authoritative — any value supplied via the payload " + - "is overwritten. " + + "result as a JSON array under the protected `tb_aclSnapshot` metadata key. The calling user's id is " + + "written under `tb_userId`. Both metadata keys are server-authoritative — any value supplied via the " + + "payload is overwritten. " + "The `payload` field is optional; a null or missing payload is treated as an empty JSON object `{}` " + "so probe-only requests (callers who want only the ACL snapshot) work without a body. " + - MSG_DESCRIPTION + MSG_DESCRIPTION + + "\n\nNote: a `SYS_ADMIN` caller operates against the system tenant, so any tenant-scoped entity " + + "(DEVICE, ASSET, CUSTOMER, …) passed in `enrichEntities` is not found by the tenant-filtered entity " + + "lookup and produces `allowed=[]` for that entry. ACL enrichment is effectively a no-op for SYS_ADMIN." + "\n\n" + ControllerConstants.SECURITY_WRITE_CHECK) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/v2/", method = RequestMethod.POST) + @RequestMapping(value = "/v2", method = RequestMethod.POST) @ResponseBody - // type(String) is the supported form for custom messageType supplied via the request body; - // the @Deprecated annotation on TbMsgBuilder.type(String) gates accidental misuse elsewhere. - @SuppressWarnings("deprecation") public DeferredResult handleEnrichedRuleEngineRequest( @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "Enriched rule engine request", required = true) @RequestBody EnrichedRuleEngineRequest request) throws ThingsboardException { @@ -255,7 +255,6 @@ public class RuleEngineController extends BaseController { } EntityId originator = request.getOriginator() != null ? request.getOriginator() : currentUser.getId(); - String messageType = request.getMessageType() != null ? request.getMessageType() : TbMsgType.REST_API_REQUEST.name(); String queueName = request.getQueueName(); int timeout = request.getTimeout() != null ? request.getTimeout() : defaultResponseTimeout; JsonNode payload = request.getPayload(); @@ -271,13 +270,13 @@ public class RuleEngineController extends BaseController { metaData.put("serviceId", serviceInfoProvider.getServiceId()); metaData.put("requestUUID", requestId.toString()); metaData.put("expirationTime", Long.toString(expTime)); - // tb_user_id and tb_acl are written last so any caller-supplied value is overwritten. + // tb_userId and tb_aclSnapshot are written last so any caller-supplied value is overwritten. metaData.put(TbMsgMetaData.TB_USER_ID_KEY, currentUser.getId().getId().toString()); metaData.put(TbMsgMetaData.TB_ACL_KEY, buildAclMetadata(currentUser, enrichEntities)); TbMsg msg = TbMsg.newMsg() .queueName(queueName) - .type(messageType) + .type(TbMsgType.REST_API_REQUEST) .originator(originator) .customerId(currentUser.getCustomerId()) .copyMetaData(new TbMsgMetaData(metaData)) @@ -317,23 +316,21 @@ public class RuleEngineController extends BaseController { try { resource = Resource.of(entityId.getEntityType()); } catch (IllegalArgumentException e) { - log.warn("[{}] tb_acl: no Resource mapping for EntityType {} (entity {}); returning empty allowed", + log.warn("[{}] tb_aclSnapshot: no Resource mapping for EntityType {} (entity {}); returning empty allowed", user.getTenantId(), entityId.getEntityType(), entityId.getId()); return new EntityAclEntry(entityId.getEntityType(), entityId.getId(), List.of()); } Optional> entityOpt; try { - entityOpt = entityServiceRegistry - .getServiceByEntityType(entityId.getEntityType()) - .findEntity(user.getTenantId(), entityId); + entityOpt = entityService.fetchEntity(user.getTenantId(), entityId); } catch (IllegalArgumentException e) { - log.warn("[{}] tb_acl: no EntityDaoService for EntityType {} (entity {}); returning empty allowed", + log.warn("[{}] tb_aclSnapshot: no EntityDaoService for EntityType {} (entity {}); returning empty allowed", user.getTenantId(), entityId.getEntityType(), entityId.getId()); return new EntityAclEntry(entityId.getEntityType(), entityId.getId(), List.of()); } if (entityOpt.isEmpty() || !(entityOpt.get() instanceof HasTenantId entity)) { - log.warn("[{}] tb_acl: entity {} {} not found (stale id, cross-tenant, or system-level); returning empty allowed", + log.warn("[{}] tb_aclSnapshot: entity {} {} not found (stale id, cross-tenant, or system-level); returning empty allowed", user.getTenantId(), entityId.getEntityType(), entityId.getId()); return new EntityAclEntry(entityId.getEntityType(), entityId.getId(), List.of()); } diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 3dc809d168..4f36b25c25 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -117,6 +117,8 @@ server: response_timeout: "${DEFAULT_RULE_ENGINE_RESPONSE_TIMEOUT:10000}" acl: # Maximum number of entities accepted in /api/rule-engine/v2 enrichEntities. Bounds DB load and permission checks per request. + # Note: setting this to 0 rejects any non-empty enrichEntities with HTTP 400 — it does NOT mean unlimited. + # Requests that omit enrichEntities (or pass an empty array) are always accepted, regardless of this value. max_entities: "${RULE_ENGINE_ACL_MAX_ENTITIES:20}" # Application info parameters diff --git a/application/src/test/java/org/thingsboard/server/controller/RuleEngineControllerV2Test.java b/application/src/test/java/org/thingsboard/server/controller/RuleEngineControllerV2Test.java index 94ea80d721..5d271b113b 100644 --- a/application/src/test/java/org/thingsboard/server/controller/RuleEngineControllerV2Test.java +++ b/application/src/test/java/org/thingsboard/server/controller/RuleEngineControllerV2Test.java @@ -52,7 +52,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. @DaoSqlTest public class RuleEngineControllerV2Test extends AbstractControllerTest { - private static final String URL = "/api/rule-engine/v2/"; + private static final String URL = "/api/rule-engine/v2"; private static final String RESPONSE_BODY = "{\"response\":\"ok\"}"; @SpyBean @@ -259,18 +259,17 @@ public class RuleEngineControllerV2Test extends AbstractControllerTest { } @Test - public void testV2HonorsBodyMessageTypeAndTimeout() throws Exception { + public void testV2ForwardsRestApiRequestTypeAndHonorsBodyTimeout() throws Exception { loginTenantAdmin(); EnrichedRuleEngineRequest request = baseRequest(); - request.setMessageType("MY_CUSTOM_TYPE"); request.setTimeout(2000); long beforeMs = System.currentTimeMillis(); TbMsg captured = doRequestAndCapture(request, tenantId); long afterMs = System.currentTimeMillis(); - assertThat(captured.getType()).isEqualTo("MY_CUSTOM_TYPE"); + assertThat(captured.getType()).isEqualTo(TbMsgType.REST_API_REQUEST.name()); long expirationTime = Long.parseLong(captured.getMetaData().getValue("expirationTime")); assertThat(expirationTime).isBetween(beforeMs + 2000, afterMs + 2000); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/rule/engine/EnrichedRuleEngineRequest.java b/common/data/src/main/java/org/thingsboard/server/common/data/rule/engine/EnrichedRuleEngineRequest.java index 5d583128bd..10fcb5f0bd 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/rule/engine/EnrichedRuleEngineRequest.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/rule/engine/EnrichedRuleEngineRequest.java @@ -17,6 +17,7 @@ package org.thingsboard.server.common.data.rule.engine; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; 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; @@ -26,11 +27,26 @@ import java.util.List; @JsonIgnoreProperties(ignoreUnknown = true) public class EnrichedRuleEngineRequest { + @Schema(description = "Originator of the forwarded TbMsg. When omitted, the calling user's id is used.") private EntityId originator; - private String messageType; + + @Schema(description = "Optional rule engine queue name. When present, overrides the queue selected by the " + + "originator's profile.") private String queueName; + + @Schema(description = "Timeout to process the request, in milliseconds. When omitted, the platform default " + + "(server.rest.rule_engine.response_timeout) is used.", + example = "10000") private Integer timeout; + + @Schema(description = "Message payload forwarded to the rule engine as TbMsg.data. A null or missing payload " + + "is treated as an empty JSON object '{}' so probe-only requests (callers who want only the ACL " + + "snapshot) work without a body.") private JsonNode payload; + + @Schema(description = "Optional list of entities for which to compute the ACL snapshot. Each entry is the " + + "platform's polymorphic EntityId form ({entityType, id}). Size is bounded by the configuration " + + "property server.rest.rule_engine.acl.max_entities; exceeding the bound yields HTTP 400.") private List enrichEntities; } 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 index 59f7fe9006..957b23beae 100644 --- 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 @@ -15,6 +15,7 @@ */ 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; @@ -23,13 +24,26 @@ import org.thingsboard.server.common.data.EntityType; import java.util.List; import java.util.UUID; +/** + * One element of the {@code tb_aclSnapshot} metadata JSON array. Describes which + * {@link org.thingsboard.server.service.security.permission.Operation} values the calling + * user is allowed to perform on the referenced entity. An empty {@link #allowed} list means + * the caller has no operations on this entity, or the entity is not addressable for ACL + * (unmapped EntityType, missing/cross-tenant entity, non-tenant-scoped entity). + */ @Data @NoArgsConstructor @AllArgsConstructor public class EntityAclEntry { + @Schema(description = "Entity type of the referenced entity.") private EntityType entityType; + + @Schema(description = "UUID of the referenced entity.") private UUID entityId; + + @Schema(description = "Names of Operation values the caller is allowed to perform on the entity. " + + "Operation names are serialized as strings so JS/TBEL rule nodes can iterate them naturally.") private List allowed; } 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 7884dfb763..a1c766ac4c 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,8 +29,20 @@ public final class TbMsgMetaData implements Serializable { public static final TbMsgMetaData EMPTY = new TbMsgMetaData(0); - public static final String TB_ACL_KEY = "tb_acl"; - public static final String TB_USER_ID_KEY = "tb_user_id"; + /** + * Reserved metadata key. Populated by the platform (e.g. the Rule Engine REST API v2 + * enrichment) with a JSON array of {@code EntityAclEntry}. The platform overwrites any + * caller-supplied value of this key before the message is forwarded to the Rule Engine, + * so rule chains may treat it as authoritative. + */ + public static final String TB_ACL_KEY = "tb_aclSnapshot"; + + /** + * 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"; private final Map data; From 4ee925c41134967ac21e36ddf826f797eb4d960b Mon Sep 17 00:00:00 2001 From: Oleksandra Matviienko Date: Tue, 12 May 2026 16:03:26 +0200 Subject: [PATCH 03/10] Refactored /api/rule-engine/v2 to fold with v1 routing and renamed DTOs - Renamed EnrichedRuleEngineRequest to RuleEngineV2Request; field enrichEntities to aclEntities; timeout from Integer to int primitive; payload marked required. - EntityAclEntry: collapsed entityType+UUID into a single EntityId; allowed switched from List to Set. - Folded v1 four-method chain and v2 endpoint into one private handleRuleEngineRequest helper; v1 path passes null aclEntities. - Moved size-limit config to top-level rule-engine.acl.max-entities (kebab); 0 now disables the bound (no upper limit) instead of rejecting any non-empty list. - ACL probe uses accessControlService.hasPermission boolean variant; unmapped EntityType and missing entity branches resolve to allowed=[] silently; permission throws are logged at debug. - Renamed test class to RuleEngineControllerV2EnrichmentTest and updated all tests to the new DTO and field shapes. --- .../controller/RuleEngineController.java | 236 ++++++++---------- .../src/main/resources/thingsboard.yml | 12 +- ...RuleEngineControllerV2EnrichmentTest.java} | 94 ++++--- .../data/rule/engine/EntityAclEntry.java | 25 +- ...eRequest.java => RuleEngineV2Request.java} | 26 +- .../server/common/msg/TbMsgMetaData.java | 14 +- 6 files changed, 185 insertions(+), 222 deletions(-) rename application/src/test/java/org/thingsboard/server/controller/{RuleEngineControllerV2Test.java => RuleEngineControllerV2EnrichmentTest.java} (80%) rename common/data/src/main/java/org/thingsboard/server/common/data/rule/engine/{EnrichedRuleEngineRequest.java => RuleEngineV2Request.java} (55%) 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 5a020b3796..cb4984125c 100644 --- a/application/src/main/java/org/thingsboard/server/controller/RuleEngineController.java +++ b/application/src/main/java/org/thingsboard/server/controller/RuleEngineController.java @@ -15,7 +15,6 @@ */ 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; @@ -44,8 +43,8 @@ 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.EnrichedRuleEngineRequest; 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; @@ -59,10 +58,11 @@ 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.Optional; +import java.util.Set; import java.util.UUID; import java.util.concurrent.TimeoutException; @@ -75,9 +75,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" + @@ -85,6 +82,28 @@ 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" + + " * **`tb_aclSnapshot`** — 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" + + "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. + */ + @Value("${rule-engine.acl.max-entities:20}") + private int maxAclEntities; + @Autowired private RuleEngineCallService ruleEngineCallService; @Autowired @@ -92,8 +111,25 @@ public class RuleEngineController extends BaseController { @Autowired private EntityService entityService; - @Value("${server.rest.rule_engine.acl.max_entities:20}") - private int maxAclEntities; + @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')") + @RequestMapping(value = "/v2", method = RequestMethod.POST) + @ResponseBody + 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) { + 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; + return handleRuleEngineRequest(entityTypeStr, entityIdStr, timeout, JacksonUtil.toString(request.getPayload()), request.getQueueName(), request.getAclEntities()); + } @ApiOperation(value = "Push user message to the rule engine (handleRuleEngineRequestForUser)", notes = MSG_DESCRIPTION_PREFIX + @@ -108,7 +144,7 @@ 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(null, null, null, defaultResponseTimeout, requestBody); + return handleRuleEngineRequest(null, null, defaultResponseTimeout, requestBody, null, null); } @ApiOperation(value = "Push entity message to the rule engine (handleRuleEngineRequestForEntity)", @@ -128,7 +164,7 @@ 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)", @@ -150,7 +186,7 @@ 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)", @@ -175,6 +211,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; @@ -183,6 +228,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<>(); @@ -195,6 +245,10 @@ public class RuleEngineController extends BaseController { metaData.put("serviceId", serviceInfoProvider.getServiceId()); metaData.put("requestUUID", requestId.toString()); metaData.put("expirationTime", Long.toString(expTime)); + // 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_KEY, buildAclSnapshot(currentUser, aclEntities)); + TbMsg msg = TbMsg.newMsg() .queueName(queueName) .type(TbMsgType.REST_API_REQUEST) @@ -225,127 +279,55 @@ public class RuleEngineController extends BaseController { } } - @ApiOperation(value = "Push enriched message to the rule engine (handleEnrichedRuleEngineRequest)", - notes = MSG_DESCRIPTION_PREFIX + - "All routing parameters (originator, queueName, timeout) are passed in the request body. " + - "Optionally accepts an `enrichEntities` list. For each entity, the controller computes the set of " + - "operations the calling user is allowed to perform on that specific entity instance and writes the " + - "result as a JSON array under the protected `tb_aclSnapshot` metadata key. The calling user's id is " + - "written under `tb_userId`. Both metadata keys are server-authoritative — any value supplied via the " + - "payload is overwritten. " + - "The `payload` field is optional; a null or missing payload is treated as an empty JSON object `{}` " + - "so probe-only requests (callers who want only the ACL snapshot) work without a body. " + - MSG_DESCRIPTION + - "\n\nNote: a `SYS_ADMIN` caller operates against the system tenant, so any tenant-scoped entity " + - "(DEVICE, ASSET, CUSTOMER, …) passed in `enrichEntities` is not found by the tenant-filtered entity " + - "lookup and produces `allowed=[]` for that entry. ACL enrichment is effectively a no-op for SYS_ADMIN." - + "\n\n" + ControllerConstants.SECURITY_WRITE_CHECK) - @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/v2", method = RequestMethod.POST) - @ResponseBody - public DeferredResult handleEnrichedRuleEngineRequest( - @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "Enriched rule engine request", required = true) - @RequestBody EnrichedRuleEngineRequest request) throws ThingsboardException { - SecurityUser currentUser = getCurrentUser(); - - List enrichEntities = request.getEnrichEntities() != null ? request.getEnrichEntities() : List.of(); - if (enrichEntities.size() > maxAclEntities) { - throw new ThingsboardException("enrichEntities exceeds the limit of " + maxAclEntities, - ThingsboardErrorCode.BAD_REQUEST_PARAMS); + /** + * Computes the ACL snapshot for the requested entities under the given user. + * For each 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, 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#TB_ACL_KEY}. + */ + String buildAclSnapshot(SecurityUser user, List entities) { + if (entities == null || entities.isEmpty()) { + return "[]"; } - - EntityId originator = request.getOriginator() != null ? request.getOriginator() : currentUser.getId(); - String queueName = request.getQueueName(); - int timeout = request.getTimeout() != null ? request.getTimeout() : defaultResponseTimeout; - JsonNode payload = request.getPayload(); - String payloadString = payload != null && !payload.isNull() ? JacksonUtil.toString(payload) : "{}"; - - DeferredResult response = new DeferredResult<>(); - accessValidator.validate(currentUser, Operation.WRITE, originator, new HttpValidationCallback(response, new FutureCallback>() { - @Override - public void onSuccess(@Nullable DeferredResult result) { - long expTime = System.currentTimeMillis() + timeout; - UUID requestId = UUID.randomUUID(); - HashMap metaData = new HashMap<>(); - metaData.put("serviceId", serviceInfoProvider.getServiceId()); - metaData.put("requestUUID", requestId.toString()); - metaData.put("expirationTime", Long.toString(expTime)); - // tb_userId and tb_aclSnapshot are written last so any caller-supplied value is overwritten. - metaData.put(TbMsgMetaData.TB_USER_ID_KEY, currentUser.getId().getId().toString()); - metaData.put(TbMsgMetaData.TB_ACL_KEY, buildAclMetadata(currentUser, enrichEntities)); - - TbMsg msg = TbMsg.newMsg() - .queueName(queueName) - .type(TbMsgType.REST_API_REQUEST) - .originator(originator) - .customerId(currentUser.getCustomerId()) - .copyMetaData(new TbMsgMetaData(metaData)) - .data(payloadString) - .build(); - ruleEngineCallService.processRestApiCallToRuleEngine(currentUser.getTenantId(), requestId, msg, queueName != null, - reply -> reply(new LocalRequestMetaData(msg, currentUser, result), reply)); + List result = new ArrayList<>(entities.size()); + for (EntityId entityId : entities) { + Set allowed = new LinkedHashSet<>(); + Resource resource; + try { + resource = Resource.of(entityId.getEntityType()); + } catch (IllegalArgumentException e) { + result.add(new EntityAclEntry(entityId, allowed)); + continue; } - - @Override - public void onFailure(Throwable e) { - ResponseEntity entity; - if (e instanceof ToErrorResponseEntity) { - entity = ((ToErrorResponseEntity) e).toErrorResponseEntity(); - } else { - entity = new ResponseEntity(HttpStatus.UNAUTHORIZED); - } - logRuleEngineCall(currentUser, originator, payloadString, null, e); - response.setResult(entity); + HasId entity = entityService.fetchEntity(user.getTenantId(), entityId).orElse(null); + if (!(entity instanceof HasTenantId tenantEntity)) { + result.add(new EntityAclEntry(entityId, allowed)); + continue; } - })); - return response; - } - - String buildAclMetadata(SecurityUser user, List enrichEntities) { - Map cache = new HashMap<>(); - List result = new ArrayList<>(enrichEntities.size()); - for (EntityId id : enrichEntities) { - EntityAclEntry entry = cache.computeIfAbsent(id, eid -> computeEntry(user, eid)); - result.add(entry); - } - return JacksonUtil.toString(result); - } - - private EntityAclEntry computeEntry(SecurityUser user, EntityId entityId) { - Resource resource; - try { - resource = Resource.of(entityId.getEntityType()); - } catch (IllegalArgumentException e) { - log.warn("[{}] tb_aclSnapshot: no Resource mapping for EntityType {} (entity {}); returning empty allowed", - user.getTenantId(), entityId.getEntityType(), entityId.getId()); - return new EntityAclEntry(entityId.getEntityType(), entityId.getId(), List.of()); - } - - Optional> entityOpt; - try { - entityOpt = entityService.fetchEntity(user.getTenantId(), entityId); - } catch (IllegalArgumentException e) { - log.warn("[{}] tb_aclSnapshot: no EntityDaoService for EntityType {} (entity {}); returning empty allowed", - user.getTenantId(), entityId.getEntityType(), entityId.getId()); - return new EntityAclEntry(entityId.getEntityType(), entityId.getId(), List.of()); - } - if (entityOpt.isEmpty() || !(entityOpt.get() instanceof HasTenantId entity)) { - log.warn("[{}] tb_aclSnapshot: entity {} {} not found (stale id, cross-tenant, or system-level); returning empty allowed", - user.getTenantId(), entityId.getEntityType(), entityId.getId()); - return new EntityAclEntry(entityId.getEntityType(), entityId.getId(), List.of()); - } - - List allowed = new ArrayList<>(); - for (Operation op : Operation.values()) { - try { - if (accessControlService.hasPermission(user, resource, op, entityId, entity)) { - allowed.add(op.name()); + 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()); } - } catch (ThingsboardException ignored) { - // role has no checker for this resource — skip. } + result.add(new EntityAclEntry(entityId, allowed)); } - return new EntityAclEntry(entityId.getEntityType(), entityId.getId(), allowed); + return JacksonUtil.toString(result); } private void reply(LocalRequestMetaData rpcRequest, TbMsg response) { diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 4f36b25c25..ddd0907396 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -115,11 +115,13 @@ server: rule_engine: # Default timeout for waiting response of REST API request to Rule Engine in milliseconds response_timeout: "${DEFAULT_RULE_ENGINE_RESPONSE_TIMEOUT:10000}" - acl: - # Maximum number of entities accepted in /api/rule-engine/v2 enrichEntities. Bounds DB load and permission checks per request. - # Note: setting this to 0 rejects any non-empty enrichEntities with HTTP 400 — it does NOT mean unlimited. - # Requests that omit enrichEntities (or pass an empty array) are always accepted, regardless of this value. - max_entities: "${RULE_ENGINE_ACL_MAX_ENTITIES:20}" + +# 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: diff --git a/application/src/test/java/org/thingsboard/server/controller/RuleEngineControllerV2Test.java b/application/src/test/java/org/thingsboard/server/controller/RuleEngineControllerV2EnrichmentTest.java similarity index 80% rename from application/src/test/java/org/thingsboard/server/controller/RuleEngineControllerV2Test.java rename to application/src/test/java/org/thingsboard/server/controller/RuleEngineControllerV2EnrichmentTest.java index 5d271b113b..090bc3ab29 100644 --- a/application/src/test/java/org/thingsboard/server/controller/RuleEngineControllerV2Test.java +++ b/application/src/test/java/org/thingsboard/server/controller/RuleEngineControllerV2EnrichmentTest.java @@ -28,8 +28,8 @@ 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.EnrichedRuleEngineRequest; 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.service.DaoSqlTest; @@ -50,7 +50,7 @@ import static org.mockito.Mockito.verify; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @DaoSqlTest -public class RuleEngineControllerV2Test extends AbstractControllerTest { +public class RuleEngineControllerV2EnrichmentTest extends AbstractControllerTest { private static final String URL = "/api/rule-engine/v2"; private static final String RESPONSE_BODY = "{\"response\":\"ok\"}"; @@ -63,8 +63,8 @@ public class RuleEngineControllerV2Test extends AbstractControllerTest { loginTenantAdmin(); Device device = createDevice("dev-tenant", "tok-1"); - EnrichedRuleEngineRequest request = baseRequest(); - request.setEnrichEntities(List.of(device.getId())); + RuleEngineV2Request request = baseRequest(); + request.setAclEntities(List.of(device.getId())); TbMsg captured = doRequestAndCapture(request, tenantId); @@ -72,8 +72,8 @@ public class RuleEngineControllerV2Test extends AbstractControllerTest { .isEqualTo(tenantAdminUserId.getId().toString()); List acl = parseAcl(captured); assertThat(acl).hasSize(1); - assertThat(acl.get(0).getEntityId()).isEqualTo(device.getId().getId()); - assertThat(acl.get(0).getEntityType()).isEqualTo(EntityType.DEVICE); + 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"); } @@ -84,8 +84,8 @@ public class RuleEngineControllerV2Test extends AbstractControllerTest { assignDeviceToCustomer(device.getId(), customerId); loginCustomerUser(); - EnrichedRuleEngineRequest request = baseRequest(); - request.setEnrichEntities(List.of(device.getId())); + RuleEngineV2Request request = baseRequest(); + request.setAclEntities(List.of(device.getId())); TbMsg captured = doRequestAndCapture(request, tenantId); @@ -102,14 +102,14 @@ public class RuleEngineControllerV2Test extends AbstractControllerTest { assignDeviceToCustomer(foreignDevice.getId(), differentCustomerId); loginCustomerUser(); - EnrichedRuleEngineRequest request = baseRequest(); - request.setEnrichEntities(List.of(foreignDevice.getId())); + 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().getId()); + 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", @@ -127,36 +127,36 @@ public class RuleEngineControllerV2Test extends AbstractControllerTest { // Customer 1 (own A, foreign B) loginCustomerUser(); - EnrichedRuleEngineRequest req1 = baseRequest(); - req1.setEnrichEntities(List.of(deviceA.getId(), deviceB.getId())); + 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().getId()); + 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().getId()); + 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() helper has a bug on its // second invocation (uses the wrong password constant), so we log in directly. login(DIFFERENT_CUSTOMER_USER_EMAIL, "diffcustomer"); - EnrichedRuleEngineRequest req2 = baseRequest(); - req2.setEnrichEntities(List.of(deviceA.getId(), deviceB.getId())); + 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().getId()); + 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().getId()); + assertThat(acl2.get(1).getEntityId()).isEqualTo(deviceB.getId()); assertThat(acl2.get(1).getAllowed()).contains("WRITE", "READ_TELEMETRY"); } @Test - public void testV2EmptyEnrichEntitiesProducesEmptyAcl() throws Exception { + public void testV2EmptyAclEntitiesProducesEmptyAcl() throws Exception { loginTenantAdmin(); - EnrichedRuleEngineRequest request = baseRequest(); - // enrichEntities left null + RuleEngineV2Request request = baseRequest(); + // aclEntities left null TbMsg captured = doRequestAndCapture(request, tenantId); assertThat(captured.getMetaData().getValue(TbMsgMetaData.TB_ACL_KEY)).isEqualTo("[]"); @@ -170,16 +170,16 @@ public class RuleEngineControllerV2Test extends AbstractControllerTest { Device device = createDevice("dev-dup", "tok-dup"); Device other = createDevice("dev-other", "tok-other"); - EnrichedRuleEngineRequest request = baseRequest(); - request.setEnrichEntities(List.of(device.getId(), device.getId(), other.getId())); + 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().getId()); - assertThat(acl.get(1).getEntityId()).isEqualTo(device.getId().getId()); - assertThat(acl.get(2).getEntityId()).isEqualTo(other.getId().getId()); + assertThat(acl.get(0).getEntityId()).isEqualTo(device.getId()); + assertThat(acl.get(1).getEntityId()).isEqualTo(device.getId()); + assertThat(acl.get(2).getEntityId()).isEqualTo(other.getId()); } @Test @@ -190,8 +190,8 @@ public class RuleEngineControllerV2Test extends AbstractControllerTest { tooMany.add(new DeviceId(UUID.randomUUID())); } - EnrichedRuleEngineRequest request = baseRequest(); - request.setEnrichEntities(tooMany); + RuleEngineV2Request request = baseRequest(); + request.setAclEntities(tooMany); doPost(URL, request).andExpect(status().isBadRequest()); } @@ -199,17 +199,17 @@ public class RuleEngineControllerV2Test extends AbstractControllerTest { @Test public void testV2UnmappedEntityTypeProducesEmptyAcl() throws Exception { loginTenantAdmin(); - // RULE_NODE has no Resource mapping — Resource.of throws, allowed=[]. + // RULE_NODE has no Resource mapping — Resource.of throws, entry resolves to allowed=[]. RuleNodeId fakeRuleNode = new RuleNodeId(UUID.randomUUID()); - EnrichedRuleEngineRequest request = baseRequest(); - request.setEnrichEntities(List.of(fakeRuleNode)); + 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).getEntityType()).isEqualTo(EntityType.RULE_NODE); + assertThat(acl.get(0).getEntityId().getEntityType()).isEqualTo(EntityType.RULE_NODE); assertThat(acl.get(0).getAllowed()).isEmpty(); } @@ -218,14 +218,14 @@ public class RuleEngineControllerV2Test extends AbstractControllerTest { loginTenantAdmin(); DeviceId ghost = new DeviceId(UUID.randomUUID()); - EnrichedRuleEngineRequest request = baseRequest(); - request.setEnrichEntities(List.of(ghost)); + 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.getId()); + assertThat(acl.get(0).getEntityId()).isEqualTo(ghost); assertThat(acl.get(0).getAllowed()).isEmpty(); } @@ -234,10 +234,10 @@ public class RuleEngineControllerV2Test extends AbstractControllerTest { loginTenantAdmin(); Device device = createDevice("dev-inj", "tok-inj"); - EnrichedRuleEngineRequest request = baseRequest(); + RuleEngineV2Request request = baseRequest(); request.setPayload(JacksonUtil.toJsonNode("{\"" + TbMsgMetaData.TB_ACL_KEY + "\":\"attack\",\"" + TbMsgMetaData.TB_USER_ID_KEY + "\":\"intruder\"}")); - request.setEnrichEntities(List.of(device.getId())); + request.setAclEntities(List.of(device.getId())); TbMsg captured = doRequestAndCapture(request, tenantId); @@ -248,21 +248,19 @@ public class RuleEngineControllerV2Test extends AbstractControllerTest { } @Test - public void testV2NullPayloadBecomesEmptyJsonObject() throws Exception { + public void testV2RequiresPayload() throws Exception { loginTenantAdmin(); - EnrichedRuleEngineRequest request = new EnrichedRuleEngineRequest(); - // payload deliberately not set — exercises the documented null/missing → "{}" path. + RuleEngineV2Request request = new RuleEngineV2Request(); + // payload deliberately not set — the v2 contract now requires it. - TbMsg captured = doRequestAndCapture(request, tenantId); - - assertThat(captured.getData()).isEqualTo("{}"); + doPost(URL, request).andExpect(status().isBadRequest()); } @Test public void testV2ForwardsRestApiRequestTypeAndHonorsBodyTimeout() throws Exception { loginTenantAdmin(); - EnrichedRuleEngineRequest request = baseRequest(); + RuleEngineV2Request request = baseRequest(); request.setTimeout(2000); long beforeMs = System.currentTimeMillis(); @@ -274,13 +272,13 @@ public class RuleEngineControllerV2Test extends AbstractControllerTest { assertThat(expirationTime).isBetween(beforeMs + 2000, afterMs + 2000); } - private EnrichedRuleEngineRequest baseRequest() { - EnrichedRuleEngineRequest request = new EnrichedRuleEngineRequest(); + private RuleEngineV2Request baseRequest() { + RuleEngineV2Request request = new RuleEngineV2Request(); request.setPayload(JacksonUtil.toJsonNode("{\"k\":\"v\"}")); return request; } - private TbMsg doRequestAndCapture(EnrichedRuleEngineRequest request, TenantId expectedTenantId) throws Exception { + private TbMsg doRequestAndCapture(RuleEngineV2Request request, TenantId expectedTenantId) throws Exception { TbMsg responseMsg = TbMsg.newMsg() .type(TbMsgType.REST_API_REQUEST) .originator(currentUserId) 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 index 957b23beae..4c88760f6b 100644 --- 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 @@ -19,31 +19,20 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; -import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.id.EntityId; -import java.util.List; -import java.util.UUID; +import java.util.Set; -/** - * One element of the {@code tb_aclSnapshot} metadata JSON array. Describes which - * {@link org.thingsboard.server.service.security.permission.Operation} values the calling - * user is allowed to perform on the referenced entity. An empty {@link #allowed} list means - * the caller has no operations on this entity, or the entity is not addressable for ACL - * (unmapped EntityType, missing/cross-tenant entity, non-tenant-scoped entity). - */ @Data @NoArgsConstructor @AllArgsConstructor public class EntityAclEntry { - @Schema(description = "Entity type of the referenced entity.") - private EntityType entityType; - - @Schema(description = "UUID of the referenced entity.") - private UUID entityId; + @Schema(description = "Target entity identifier (entity type and UUID).") + private EntityId entityId; - @Schema(description = "Names of Operation values the caller is allowed to perform on the entity. " + - "Operation names are serialized as strings so JS/TBEL rule nodes can iterate them naturally.") - private List allowed; + @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/EnrichedRuleEngineRequest.java b/common/data/src/main/java/org/thingsboard/server/common/data/rule/engine/RuleEngineV2Request.java similarity index 55% rename from common/data/src/main/java/org/thingsboard/server/common/data/rule/engine/EnrichedRuleEngineRequest.java rename to common/data/src/main/java/org/thingsboard/server/common/data/rule/engine/RuleEngineV2Request.java index 10fcb5f0bd..ab81198451 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/rule/engine/EnrichedRuleEngineRequest.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/rule/engine/RuleEngineV2Request.java @@ -15,7 +15,6 @@ */ package org.thingsboard.server.common.data.rule.engine; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.databind.JsonNode; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @@ -24,29 +23,22 @@ import org.thingsboard.server.common.data.id.EntityId; import java.util.List; @Data -@JsonIgnoreProperties(ignoreUnknown = true) -public class EnrichedRuleEngineRequest { +public class RuleEngineV2Request { - @Schema(description = "Originator of the forwarded TbMsg. When omitted, the calling user's id is used.") + @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. When present, overrides the queue selected by the " + - "originator's profile.") + @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, the platform default " + - "(server.rest.rule_engine.response_timeout) is used.", + @Schema(description = "Timeout to process the request, in milliseconds. When omitted or <= 0, the platform default is used.", example = "10000") - private Integer timeout; + private int timeout; - @Schema(description = "Message payload forwarded to the rule engine as TbMsg.data. A null or missing payload " + - "is treated as an empty JSON object '{}' so probe-only requests (callers who want only the ACL " + - "snapshot) work without a body.") + @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. Each entry is the " + - "platform's polymorphic EntityId form ({entityType, id}). Size is bounded by the configuration " + - "property server.rest.rule_engine.acl.max_entities; exceeding the bound yields HTTP 400.") - private List enrichEntities; - + @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 a1c766ac4c..a3c533f206 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 @@ -30,17 +30,17 @@ 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}. The platform overwrites any - * caller-supplied value of this key before the message is forwarded to the Rule Engine, - * so rule chains may treat it as authoritative. + * 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 TB_ACL_KEY = "tb_aclSnapshot"; /** - * 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. + * 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"; From b78bae75d111372811195d1de832f6e914187073 Mon Sep 17 00:00:00 2001 From: Oleksandra Matviienko Date: Tue, 12 May 2026 17:12:13 +0200 Subject: [PATCH 04/10] Marked v1 rule-engine endpoints as @Deprecated in favor of /v2 Added @Deprecated(since = "4.3") to all four /api/rule-engine v1 endpoints and prefixed the @ApiOperation.notes with a deprecation note pointing callers to POST /api/rule-engine/v2 (single endpoint, body-based routing, optional ACL enrichment). --- .../server/controller/RuleEngineController.java | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 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 cb4984125c..98720512c8 100644 --- a/application/src/main/java/org/thingsboard/server/controller/RuleEngineController.java +++ b/application/src/main/java/org/thingsboard/server/controller/RuleEngineController.java @@ -132,7 +132,8 @@ public class RuleEngineController extends BaseController { } @ApiOperation(value = "Push user message to the rule engine (handleRuleEngineRequestForUser)", - notes = MSG_DESCRIPTION_PREFIX + + notes = "Deprecated since 4.3. 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." @@ -140,6 +141,7 @@ public class RuleEngineController extends BaseController { @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/", method = RequestMethod.POST) @ResponseBody + @Deprecated(since = "4.3") 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"))) @@ -148,7 +150,8 @@ public class RuleEngineController extends BaseController { } @ApiOperation(value = "Push entity message to the rule engine (handleRuleEngineRequestForEntity)", - notes = MSG_DESCRIPTION_PREFIX + + notes = "Deprecated since 4.3. 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." @@ -156,6 +159,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") public DeferredResult handleRuleEngineRequestForEntity( @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @PathVariable("entityType") String entityType, @@ -168,7 +172,8 @@ public class RuleEngineController extends BaseController { } @ApiOperation(value = "Push entity message with timeout to the rule engine (handleRuleEngineRequestForEntityWithTimeout)", - notes = MSG_DESCRIPTION_PREFIX + + notes = "Deprecated since 4.3. 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." @@ -176,6 +181,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") public DeferredResult handleRuleEngineRequestForEntityWithTimeout( @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @PathVariable("entityType") String entityType, @@ -190,7 +196,8 @@ public class RuleEngineController extends BaseController { } @ApiOperation(value = "Push entity message with timeout and specified queue to the rule engine (handleRuleEngineRequestForEntityWithQueueAndTimeout)", - notes = MSG_DESCRIPTION_PREFIX + + notes = "Deprecated since 4.3. 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. " + @@ -199,6 +206,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") public DeferredResult handleRuleEngineRequestForEntityWithQueueAndTimeout( @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @PathVariable("entityType") String entityType, From 15f1b9fe336368a3e18e16b0f2c08a61cd201a6f Mon Sep 17 00:00:00 2001 From: Oleksandra Matviienko Date: Tue, 12 May 2026 17:24:15 +0200 Subject: [PATCH 05/10] Removed RuleEngineController-acl-enrichment-per-entity-design.md design doc --- ...roller-acl-enrichment-per-entity-design.md | 377 ------------------ 1 file changed, 377 deletions(-) delete mode 100644 RuleEngineController-acl-enrichment-per-entity-design.md diff --git a/RuleEngineController-acl-enrichment-per-entity-design.md b/RuleEngineController-acl-enrichment-per-entity-design.md deleted file mode 100644 index 593145b97a..0000000000 --- a/RuleEngineController-acl-enrichment-per-entity-design.md +++ /dev/null @@ -1,377 +0,0 @@ -# Rule Engine ACL Enrichment — Per-Entity Permissions (Design v2) - -**Date:** 2026-05-12 -**Author:** Oleksandra Matviienko -**Status:** Approved for implementation; implementation merged in this PR -**Scope:** ThingsBoard CE (branch: based on `lts-4.3`) -**Supersedes:** the role-level design (issue #15496 / prior `RuleEngineController-acl-enrichment-design.md`) - ---- - -## 1. Problem Statement - -When the REST API pushes a message to the Rule Engine, the engine runs under effectively-root privileges. Rule chains can read, write, or delete any entity without knowing whether the *calling user* was actually authorized to do so. The only permission check happens in the controller against a single originator entity (`Operation.WRITE`); once the `TbMsg` enters the queue, user identity and authorization context are lost. - -This prevents rule-chain authors from building flows whose behavior depends on the caller's permissions — e.g., "allow this action only if the caller can WRITE to asset X", or "log who initiated this action". - -## 2. Goal - -Add an **optional, additive, non-breaking** enrichment path to the Rule Engine REST API that: - -1. Accepts a list of entities from the caller alongside the payload. -2. Computes, server-side, **per-entity** — the set of operations the calling user is allowed to perform on each *specific* entity instance. -3. Writes that ACL snapshot into **protected** `TbMsg` metadata keys the caller cannot override. -4. Also writes the caller's user id, for audit/logging inside rule chains. -5. Forwards the enriched `TbMsg` to the Rule Engine as usual. - -Rule chains are free to ignore the metadata entirely — existing flows are unaffected. - -## 3. Non-Goals - -- Denying the request at the API layer based on the ACL snapshot. Decisions live in the rule chain. -- Modifying or replacing the existing `POST /api/rule-engine/...` v1 endpoints. This adds new `/v2` endpoint alongside them. -- Changing `AccessValidator`, `AccessControlService`, `Resource`, `Operation`, or permission-model semantics. We consume them as-is. -- UI changes. -- Extending enrichment to controllers other than `RuleEngineController` (see §13). - -## 4. API Contract - -A single new endpoint with all routing parameters in the request body: - -- `POST /api/rule-engine/v2` - -Same authorization as v1: `hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')` and existing `AccessValidator` WRITE check on the originator. - -### Request body - -```json -{ - "originator": { "entityType": "DEVICE", "id": "784f394c-42b6-..." }, - "queueName": "Main", - "timeout": 10000, - "payload": { "any": "user json" }, - "enrichEntities": [ - { "entityType": "DEVICE", "id": "784f394c-42b6-..." }, - { "entityType": "ASSET", "id": "abc-123-..." } - ] -} -``` - -- `originator` — optional `EntityId`. If absent, defaults to the calling user's id (matches v1 behavior when path variables are omitted). -- The `TbMsg` type is hardcoded to `REST_API_REQUEST` (matches v1 across all four ruleEngine endpoints; this is the documented input type for the terminal `rest call reply` rule node, see https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/rest-call-reply/). -- `queueName` — optional. When present, overrides the queue selected by the originator's profile (same semantics as v1). -- `timeout` — optional, milliseconds. Defaults to `server.rest.rule_engine.response_timeout` (10s). -- `payload` — optional JSON. A null or missing payload is treated as the empty JSON object `{}` so probe-only requests (callers who want only the ACL snapshot) work without a body. -- `enrichEntities` — optional list of `EntityId` (uses the platform's existing polymorphic `EntityId` Jackson representation: `{entityType, id}`). If absent or empty, `tb_aclSnapshot` is `"[]"`. -- Size limit: configurable via `server.rest.rule_engine.acl.max_entities` (default **20**). Exceeding returns `400 Bad Request`. - -### Response - -Unchanged from v1: `DeferredResult` populated by the Rule Engine's terminal "REST Call Reply" node (or `408`/`504` on timeout). - -## 5. Protected Metadata - -Two new keys, both **always written last** when building `TbMsgMetaData`, so any user-supplied value (if a caller tried to sneak them in via the payload or elsewhere) is unconditionally overwritten. - -### 5.1 `tb_aclSnapshot` - -- Key constant: `TbMsgMetaData.TB_ACL_KEY = "tb_aclSnapshot"` -- Value: JSON string (serialized `List`). -- Order: matches the order of `enrichEntities` in the request (deterministic). -- Duplicate `EntityId` values in `enrichEntities` produce duplicate entries in the output in the same positions; the underlying entity is loaded only once (dedup cache per request). -- Shape: - -```json -[ - { - "entityType": "DEVICE", - "entityId": "784f394c-42b6-...", - "allowed": ["READ", "WRITE", "READ_ATTRIBUTES", "WRITE_ATTRIBUTES", "READ_TELEMETRY"] - }, - { - "entityType": "ASSET", - "entityId": "abc-123-...", - "allowed": [] - } -] -``` - -- **`allowed` — per-entity capability**: names from the `Operation` enum the caller can perform on *this specific entity instance* (not on the resource type in the abstract). For Customer Users whose access depends on entity assignment, this correctly reflects whether the caller can act on the concrete entity. -- An empty `allowed` list means either: (a) the user has no operations on this entity, (b) the entity was not found / cross-tenant / not `HasTenantId`, or (c) the `EntityType` has no `Resource` mapping. Server-side WARN logs distinguish (b) and (c) for operations debugging; rule-chain authors can treat empty as a single "no access" signal. -- Format deliberately an array of flat objects (no compound keys like `"DEVICE:uuid"`) so it iterates naturally in JS and TBEL rule nodes. - -### 5.2 `tb_userId` - -- Key constant: `TbMsgMetaData.TB_USER_ID_KEY = "tb_userId"` -- Value: UUID string of the calling user (`currentUser.getId().getId().toString()`). -- Stable across rename/email changes. -- `tenantId` and `customerId` are already recoverable from `TbMsg` (originator, customerId), so they are not duplicated here. - -## 6. Computation Logic - -For each `EntityId` in `enrichEntities`, **after** size-limit validation: - -1. **Map `EntityType → Resource`** via existing `Resource.of(entityType)`. If it throws `IllegalArgumentException` (no `Resource` for this type — e.g., `RULE_NODE`), log WARN and record the entity with `allowed=[]`. Continue. -2. **Load the entity** via `entityServiceRegistry.getServiceByEntityType(entityType).findEntity(user.getTenantId(), entityId)`: - - The `tenantId` argument means the DAO-level filter rejects cross-tenant ids → `Optional.empty()`. - - If `getServiceByEntityType` throws `IllegalArgumentException` (no DAO registered for this type), log WARN, `allowed=[]`, continue. - - If the result is `Optional.empty()` (entity does not exist, cross-tenant, or was just deleted), log WARN, `allowed=[]`, continue. - - If the loaded object does not implement `HasTenantId` (rare system-level entity), log WARN, `allowed=[]`, continue. -3. **For each value in `Operation.values()`**: - - Call `accessControlService.hasPermission(user, resource, op, entityId, entity)` — the **per-entity, boolean-returning** form. - - `true` → add `op.name()` to `allowed`. - - `false` → skip silently (normal "you don't have this op" case). - - Throws `ThingsboardException` → skip silently (the rare "this authority has no checker for this resource" wiring case; defensive catch). -4. Append `new EntityAclEntry(entityType, entityId.getId(), allowed)` to the result list. - -### Deduplication - -`buildAclMetadata` keeps a `Map` cache for the duration of one request. If `enrichEntities` repeats the same id, the per-entity computation runs once; subsequent occurrences look up the same `EntityAclEntry` reference. The output list preserves duplicates in input order (contract), but DB load and permission probes do not. - -### Why per-entity, not role-level - -The 3-argument `hasPermission(user, resource, operation)` form asks "does this *authority* permit this op on this *resource type* in general?" — it doesn't take the entity. For `CUSTOMER_USER`, that returns `true` for `WRITE` on **any** `DEVICE`, because the role abstractly grants WRITE on devices. The actual per-instance check (`user.customerId == entity.customerId`) lives in the 5-argument `hasPermission(user, resource, op, entityId, entity)` form. To call it, we must first load the entity object — which is what step 2 above provides via `EntityServiceRegistry`. - -This is the core deviation from the original (role-level) design in issue #15496. - -### Size limit - -`enrichEntities.size() > maxAclEntities` → `ThingsboardException(BAD_REQUEST_PARAMS)` before any entity load. - -## 7. Component Layout (Variant A — inline in controller) - -### New files - -- `common/data/src/main/java/org/thingsboard/server/common/data/rule/engine/EnrichedRuleEngineRequest.java` - - Fields: `EntityId originator`, `String queueName`, `Integer timeout`, `JsonNode payload`, `List enrichEntities` - - `@Data` + `@JsonIgnoreProperties(ignoreUnknown = true)` -- `common/data/src/main/java/org/thingsboard/server/common/data/rule/engine/EntityAclEntry.java` - - Fields: `EntityType entityType`, `UUID entityId`, `List allowed` - - `@Data @NoArgsConstructor @AllArgsConstructor` - - Flat shape (not `EntityId`) so JS/TBEL nodes can iterate naturally. - -### Modified files - -- `common/message/.../TbMsgMetaData.java` - - Add `public static final String TB_ACL_KEY = "tb_aclSnapshot";` - - Add `public static final String TB_USER_ID_KEY = "tb_userId";` -- `application/.../controller/RuleEngineController.java` - - `AccessControlService accessControlService` is already inherited from `BaseController`. - - Inject `EntityService entityService` — required to load the target entity (the per-entity `hasPermission` form needs the entity object, not just the id). The internal call `entityService.fetchEntity(tenantId, entityId)` dispatches via `EntityServiceRegistry` and returns `Optional>`. - - Add `@Value("${server.rest.rule_engine.acl.max_entities:20}") int maxAclEntities;` - - Add a single `POST /api/rule-engine/v2` endpoint method `handleEnrichedRuleEngineRequest(@RequestBody EnrichedRuleEngineRequest)`. - - Add private helpers `buildAclMetadata(SecurityUser, List)` and `computeEntry(SecurityUser, EntityId)`. The latter is the per-entity computation described in §6. - - The forwarded `TbMsg` type is hardcoded to `TbMsgType.REST_API_REQUEST` — matches v1 behavior across all four ruleEngine endpoints. No custom `messageType` input from the body, so the deprecated `TbMsg.TbMsgBuilder.type(String)` form is not needed. - - The flow: parse `EnrichedRuleEngineRequest` → resolve `originator` (body or current user), `queueName` (body or null), `timeout` (body or `defaultResponseTimeout`), `payload` (body or `{}`) → validate `enrichEntities.size() ≤ maxAclEntities` → existing `AccessValidator` WRITE check on originator → inside `onSuccess()`, compute ACL via `buildAclMetadata(...)` → populate metadata in the order `serviceId, requestUUID, expirationTime, tb_userId, tb_aclSnapshot` → build `TbMsg` with `type = TbMsgType.REST_API_REQUEST` and `data = payloadString` → call `ruleEngineCallService.processRestApiCallToRuleEngine(...)` exactly as today. -- `application/src/main/resources/thingsboard.yml` - - Add the `acl.max_entities` property under the existing `server.rest.rule_engine` section (where `response_timeout` already lives). The original issue placed it under a top-level `rule-engine:` section, which does not exist in `thingsboard.yml`; co-locating it with `response_timeout` matches the local convention. - -### Zero impact on - -- v1 endpoints (`POST /api/rule-engine/...`) — untouched. -- `AccessValidator`, `AccessControlService`, `Resource`, `Operation` — consumed only. -- `RuleEngineCallService`, `TbClusterService` — unchanged; they receive a `TbMsg` that simply has extra metadata keys. -- Downstream rule nodes — consume or ignore the new metadata at their discretion. - -## 8. Flow Diagram - -``` - ┌──────┐ POST /api/rule-engine/v2 - │ │ { originator, queueName, timeout, - │ User │ payload, enrichEntities:[{entityType,id},...] } - │ │─────────────────────────┐ - │ │ │ - └──────┘ ▼ - ┌──────────────────────┐ - │ RuleEngineController │ - │ /v2 endpoint │ - └───────┬──────────────┘ - │ 1. parse JSON → EnrichedRuleEngineRequest - │ (resolve defaults: originator, - │ timeout, queueName, - │ payload null/missing → "{}") - │ 2. validate size ≤ maxAclEntities - │ 3. AccessValidator WRITE on originator - │ (existing check, unchanged) - ▼ - ┌──────────────────────┐ - │ buildAclMetadata() │ - │ (inline helper) │ - └───────┬──────────────┘ - │ for each entity in input order: - │ cache.computeIfAbsent(id, → - │ Resource.of(entityType) - │ entityServiceRegistry - │ .getServiceByEntityType(t) - │ .findEntity(tenantId, id) - │ for each Operation: - │ hasPermission(user, res, op, - │ entityId, entity) - │ ) - │ serialize List - ▼ - ┌──────────────────────┐ - │ TbMsgMetaData │ - │ serviceId │ - │ requestUUID │ - │ expirationTime │ - │ tb_userId ◀──────── SERVER - │ tb_aclSnapshot ◀──────── OVERWRITE (always last) - └───────┬──────────────┘ - │ build TbMsg(data=payload, metaData=above) - ▼ - ┌──────────────────────┐ - │ RuleEngineCallService│ - │ (unchanged) │ - └───────┬──────────────┘ - │ push to queue - ▼ - ┌──────────────────────┐ - │ Rule Engine │ - │ rule chain │ - │ script/switch nodes│ - │ read metadata.tb_aclSnapshot│ - │ read metadata.tb_userId│ - │ decide: pass/block │ - │ → REST Call Reply │ - └───────┬──────────────┘ - │ response TbMsg - ▼ - ┌──────────────────────┐ - │ Response to User │ - │ (200 or whatever │ - │ the rule chain set)│ - └──────────────────────┘ -``` - -## 9. Rule Node Usage Examples - -> Entries use a flat `{entityType, entityId, allowed[]}` shape so JS/TBEL nodes don't -> need to know about the polymorphic `EntityId` form. -> -> `allowed` reflects the **per-entity** capability — operations the calling user can -> perform on that specific entity instance. Empty `allowed` means the user has no -> access on the entity (for any reason: no permissions, stale id, unsupported type). - -### JS script node — gate by per-entity capability - -```js -var acl = JSON.parse(metadata.tb_aclSnapshot); - -var canWriteTargetDevice = acl.some(function(e) { - return e.entityType === 'DEVICE' - && e.entityId === msg.targetDeviceId - && e.allowed.indexOf('WRITE') >= 0; -}); - -var userId = metadata.tb_userId; -metadata.auditActor = userId; - -return { msg: msg, metadata: metadata, msgType: canWriteTargetDevice ? 'Allowed' : 'Denied' }; -``` - -### TBEL script node - -``` -var acl = JSON.parse(metadata.tb_aclSnapshot); -var canWriteTargetDevice = false; -foreach (e : acl) { - if (e.entityType == "DEVICE" && e.entityId == msg.targetDeviceId - && e.allowed.contains("WRITE")) { - canWriteTargetDevice = true; - break; - } -} -metadata.auditActor = metadata.tb_userId; -return {msg: msg, metadata: metadata, msgType: canWriteTargetDevice ? "Allowed" : "Denied"}; -``` - -### Filter pattern (entities the caller can WRITE) - -```js -var writable = JSON.parse(metadata.tb_aclSnapshot) - .filter(function(e) { return e.allowed.indexOf('WRITE') >= 0; }) - .map(function(e) { return e.entityType + ':' + e.entityId; }); -``` - -## 10. Error Handling - -| Condition | Response | -|---|---| -| Malformed JSON body | `400 Bad Request` (existing behavior) | -| `enrichEntities.size() > maxAclEntities` | `400 Bad Request` with message | -| Unknown `entityType` (`EntityIdDeserializer` fails) | `400 Bad Request` (existing behavior) | -| No WRITE on originator | `401 Unauthorized` (existing `AccessValidator`) | -| `EntityType` has no matching `Resource` | Entry with `allowed=[]`; WARN log; no HTTP error | -| `EntityType` has no `EntityDaoService` registered | Entry with `allowed=[]`; WARN log; no HTTP error | -| Entity not found / cross-tenant / not `HasTenantId` | Entry with `allowed=[]`; WARN log; no HTTP error | -| Operation inapplicable to role (throws) | Silently skipped from `allowed` — no error | -| Null/missing `payload` | Treated as empty JSON object `{}` — no error | -| Rule Engine processing timeout | `408 Request Timeout` (existing) | - -## 11. Security Considerations - -- **Immutable server fields.** `tb_aclSnapshot` and `tb_userId` are written by the controller after all other metadata population, so any caller-supplied value of the same key is overwritten. `TbMsgMetaData.putValue(...)` is replace semantics — no array/append path. -- **No privilege escalation.** The ACL snapshot reports *only* what the user already has on each specific entity. It can never grant more than the user can do directly. -- **Information disclosure.** The snapshot tells the rule chain (which the user cannot read directly) what the caller is allowed to do on each listed entity. Since the caller supplied the list and the data is about themselves, this is not a new information leak. The response shape for an entity the caller cannot READ is **indistinguishable** from the shape for a nonexistent entity — both produce `{entityType, entityId, allowed: []}` — so the endpoint cannot be used as an existence probe for tenant-scoped entities outside the caller's access scope. -- **DoS vector.** Bounded by: - - up to `max-entities` entity loads from the DAO (≤20 SELECT statements per request — many DAOs have L2 cache, so repeated lookups within a session are essentially free); - - `max-entities × Operation.values().length` in-memory permission checks (≤20 × 18 = 360 checks worst case). - Dedup cache (§6) collapses repeated ids in a single request to one load + one check set. -- **Audit.** `tb_userId` inside rule chains lets authors emit richer audit events that today's `REST_API_RULE_ENGINE_CALL` audit log does not propagate. - -## 12. Test Plan - -### Integration (controller) - -All tests in `RuleEngineControllerV2Test`, `@DaoSqlTest`, extending `AbstractControllerTest`, with `@SpyBean RuleEngineCallService` to capture the forwarded `TbMsg`. - -- Tenant admin + own DEVICE → `allowed` contains at least READ/WRITE/DELETE/WRITE_TELEMETRY; `tb_userId` equals caller UUID. -- Customer user + DEVICE assigned to the user's customer → `allowed` contains the customer-grade operations (WRITE, READ_TELEMETRY, etc.). -- Customer user + DEVICE assigned to a DIFFERENT customer → `allowed` does not contain WRITE/READ/READ_TELEMETRY (per-entity check correctly denies). Note: the platform allows `CLAIM_DEVICES` on any tenant device by design — that is expected to be present. -- Two-customer cross-scenario: deviceA on customer1, deviceB on customer2; customer1 user sees WRITE on A and not on B; customer2 user sees the inverse. -- Empty or null `enrichEntities` → `tb_aclSnapshot = "[]"`; `tb_userId` still present. -- Duplicate ids in `enrichEntities` → output preserves order and multiplicity. -- `enrichEntities.size() > max` → HTTP 400. -- `RULE_NODE` (no `Resource` mapping) → entry with `allowed=[]`, no error. -- Nonexistent UUID for a valid `EntityType` → entry with `allowed=[]`, no error. -- Caller embeds `{"tb_aclSnapshot": "attack", "tb_userId": "intruder"}` in `payload` → forwarded metadata keeps server-computed values; the caller's keys never reach the rule engine. -- Body `timeout=...` → forwarded `expirationTime` reflects the body value; `TbMsg.type` is always `REST_API_REQUEST` (matches v1). -- Null/missing `payload` → forwarded `TbMsg.data` is `"{}"`. -- v1 endpoints regression suite (`RuleEngineControllerTest`) continues to pass. - -## 13. Out of Scope / Future Work - -- **Extending enrichment to other controllers that push to the Rule Engine** (e.g., `TelemetryController`, `RpcController`). They have the same "message in RE has no user context" gap. Each would be its own additive v2 endpoint. If/when we need this in more than one controller, it would also motivate extracting `buildAclMetadata` / `computeEntry` into a shared `AclEnrichmentService` (Variant B from the original design — deferred until a real second caller exists). -- **Additional enriched fields** (e.g., `tb_user_email`, `tb_user_authority`) if rule-chain authors ask for them. Each is a small additive change. We avoid speculative addition — extra fields mean larger metadata and more invariants to keep stable across renames. -- **Batched / parallel entity load.** `buildAclMetadata` currently calls `entityService.fetchEntity(...)` sequentially on the validation-callback thread. With `max-entities=20` the worst case is 20 sequential DAO reads before the message is forwarded; many entity DAOs use L2 cache so warm-path cost is much lower. A batch API returning the `TenantEntity`-typed objects required by the per-entity `hasPermission` form (today's `fetchEntityInfos` returns `EntityInfo` only, which is insufficient) would let us collapse this to a single round trip. Deferred until the bound becomes a measured problem. - -## 14. Open Questions - -None at approval time. - ---- - -## 15. Manual Smoke-Test Recipe - -1. Start ThingsBoard locally (Postgres + core + rule-engine node). -2. Log in as Tenant Admin. Create a simple rule chain: - - **Script** node reading `metadata.tb_aclSnapshot` and setting `msgType` to `Allowed` or `Denied`. - - **REST Call Reply** node. -3. Set this rule chain as root (or route via `queueName`). -4. Call: - - ```bash - curl -X POST http://localhost:8080/api/rule-engine/v2 \ - -H "Content-Type: application/json" \ - -H "X-Authorization: Bearer $JWT" \ - -d '{ - "payload": {"k":"v"}, - "enrichEntities": [ - {"entityType":"DEVICE","id":""} - ] - }' - ``` - -5. Expected: HTTP 200 with body `{"msgType":"Allowed"}` (tenant admin) or `{"msgType":"Denied"}` (customer user without per-entity access). -6. In Rule Chain debug mode, inspect the incoming `TbMsg` — `metadata.tb_aclSnapshot` and `metadata.tb_userId` are present and authoritative. From 509fd6e00fb86799bc965e999ed87691f33e794c Mon Sep 17 00:00:00 2001 From: Oleksandra Matviienko Date: Tue, 12 May 2026 17:30:39 +0200 Subject: [PATCH 06/10] Switched /v2 mapping to @PostMapping and corrected deprecation version - /v2 endpoint now uses @PostMapping("/v2") instead of @RequestMapping + RequestMethod.POST; @ResponseBody dropped (redundant under @RestController). - @Deprecated(since = "4.3") on v1 endpoints bumped to "4.3.1.2" matching the actual platform version from pom.xml. Same wording fix applied to the "Deprecated since ..." prefix in the four @ApiOperation.notes. --- .../controller/RuleEngineController.java | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 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 98720512c8..e1024f63c0 100644 --- a/application/src/main/java/org/thingsboard/server/controller/RuleEngineController.java +++ b/application/src/main/java/org/thingsboard/server/controller/RuleEngineController.java @@ -27,6 +27,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; @@ -115,8 +116,7 @@ public class RuleEngineController extends BaseController { 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')") - @RequestMapping(value = "/v2", method = RequestMethod.POST) - @ResponseBody + @PostMapping("/v2") public DeferredResult handleRuleEngineRequestV2( @Parameter(description = "Enriched request body containing `payload` and optional `aclEntities`.", required = true) @RequestBody RuleEngineV2Request request) throws ThingsboardException { @@ -132,7 +132,7 @@ public class RuleEngineController extends BaseController { } @ApiOperation(value = "Push user message to the rule engine (handleRuleEngineRequestForUser)", - notes = "Deprecated since 4.3. Prefer `POST /api/rule-engine/v2` which accepts all routing parameters in the request body and supports optional ACL enrichment.\n\n" + + 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 + @@ -141,7 +141,7 @@ public class RuleEngineController extends BaseController { @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/", method = RequestMethod.POST) @ResponseBody - @Deprecated(since = "4.3") + @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"))) @@ -150,7 +150,7 @@ public class RuleEngineController extends BaseController { } @ApiOperation(value = "Push entity message to the rule engine (handleRuleEngineRequestForEntity)", - notes = "Deprecated since 4.3. Prefer `POST /api/rule-engine/v2` which accepts all routing parameters in the request body and supports optional ACL enrichment.\n\n" + + 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 + @@ -159,7 +159,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") + @Deprecated(since = "4.3.1.2") public DeferredResult handleRuleEngineRequestForEntity( @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @PathVariable("entityType") String entityType, @@ -172,7 +172,7 @@ public class RuleEngineController extends BaseController { } @ApiOperation(value = "Push entity message with timeout to the rule engine (handleRuleEngineRequestForEntityWithTimeout)", - notes = "Deprecated since 4.3. Prefer `POST /api/rule-engine/v2` which accepts all routing parameters in the request body and supports optional ACL enrichment.\n\n" + + 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 + @@ -181,7 +181,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") + @Deprecated(since = "4.3.1.2") public DeferredResult handleRuleEngineRequestForEntityWithTimeout( @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @PathVariable("entityType") String entityType, @@ -196,7 +196,7 @@ public class RuleEngineController extends BaseController { } @ApiOperation(value = "Push entity message with timeout and specified queue to the rule engine (handleRuleEngineRequestForEntityWithQueueAndTimeout)", - notes = "Deprecated since 4.3. Prefer `POST /api/rule-engine/v2` which accepts all routing parameters in the request body and supports optional ACL enrichment.\n\n" + + 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 + @@ -206,7 +206,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") + @Deprecated(since = "4.3.1.2") public DeferredResult handleRuleEngineRequestForEntityWithQueueAndTimeout( @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @PathVariable("entityType") String entityType, From a4ce19748c5ac783a86857ff6572c79056b0233e Mon Sep 17 00:00:00 2001 From: Oleksandra Matviienko Date: Tue, 12 May 2026 18:33:30 +0200 Subject: [PATCH 07/10] Restored v1 byte-identity, restored dedup cache, snake-cased config - v1 endpoints no longer emit tb_userId or tb_aclSnapshot. Shared handleRuleEngineRequest helper now writes the two server-authoritative metadata keys only when aclEntities is non-null; v1 wrappers pass null and skip the writes. v2 endpoint passes an empty List when the caller omits aclEntities so the keys are always written on the v2 path. - Restored per-request dedup cache (Map) in buildAclSnapshot. Output preserves duplicates and input order; fetchEntity and the per-Operation hasPermission probes run once per unique id. - Broadened computeAclEntry to catch IllegalArgumentException from entityService.fetchEntity so EntityTypes without a registered EntityDaoService resolve to allowed=[] instead of bubbling a 500. - buildAclSnapshot tightened to private (no external callers). - Config: rule-engine.acl.max-entities renamed to rule_engine.acl.max_entities in both yml and @Value to match thingsboard.yml's snake_case convention. - TbMsgMetaData.TB_ACL_KEY constant renamed to TB_ACL_SNAPSHOT_KEY so the identifier matches the wire value "tb_aclSnapshot"; values themselves remain camelCase to align with existing metadata key conventions (serviceId, requestUUID, userId, ...). - Tests: @TestPropertySource pins the limit at 5 so the exceeds-max-entities test is independent of the production default; new case covers empty aclEntities list; duplicate-entities test now asserts via Mockito that fetchEntity is invoked once per unique id. --- .../controller/RuleEngineController.java | 84 ++++++++++++------- .../src/main/resources/thingsboard.yml | 4 +- .../RuleEngineControllerV2EnrichmentTest.java | 40 +++++++-- .../server/common/msg/TbMsgMetaData.java | 2 +- 4 files changed, 88 insertions(+), 42 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 e1024f63c0..fd270c7f36 100644 --- a/application/src/main/java/org/thingsboard/server/controller/RuleEngineController.java +++ b/application/src/main/java/org/thingsboard/server/controller/RuleEngineController.java @@ -63,6 +63,7 @@ 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; @@ -100,9 +101,9 @@ public class RuleEngineController extends BaseController { * 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. + * Set to 0 to disable the bound entirely (no upper limit). */ - @Value("${rule-engine.acl.max-entities:20}") + @Value("${rule_engine.acl.max_entities:20}") private int maxAclEntities; @Autowired @@ -128,7 +129,10 @@ public class RuleEngineController extends BaseController { EntityId originator = request.getOriginator(); String entityTypeStr = originator != null ? originator.getEntityType().name() : null; String entityIdStr = originator != null ? originator.getId().toString() : null; - return handleRuleEngineRequest(entityTypeStr, entityIdStr, timeout, JacksonUtil.toString(request.getPayload()), request.getQueueName(), request.getAclEntities()); + // 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); } @ApiOperation(value = "Push user message to the rule engine (handleRuleEngineRequestForUser)", @@ -253,9 +257,11 @@ public class RuleEngineController extends BaseController { metaData.put("serviceId", serviceInfoProvider.getServiceId()); metaData.put("requestUUID", requestId.toString()); metaData.put("expirationTime", Long.toString(expTime)); - // 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_KEY, buildAclSnapshot(currentUser, aclEntities)); + 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)); + } TbMsg msg = TbMsg.newMsg() .queueName(queueName) @@ -289,53 +295,67 @@ public class RuleEngineController extends BaseController { /** * Computes the ACL snapshot for the requested entities under the given user. - * For each entity, the target is loaded via + * 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, or entities - * that are not {@link HasTenantId} produce {@code allowed=[]}. + * 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#TB_ACL_KEY}. + * @return serialized JSON array suitable for writing into {@link TbMsgMetaData#TB_ACL_SNAPSHOT_KEY}. */ - String buildAclSnapshot(SecurityUser user, List entities) { + 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) { - Set allowed = new LinkedHashSet<>(); - Resource resource; + 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 { - resource = Resource.of(entityId.getEntityType()); - } catch (IllegalArgumentException e) { - result.add(new EntityAclEntry(entityId, allowed)); - continue; - } - HasId entity = entityService.fetchEntity(user.getTenantId(), entityId).orElse(null); - if (!(entity instanceof HasTenantId tenantEntity)) { - result.add(new EntityAclEntry(entityId, allowed)); - continue; - } - 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()); + 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()); } - result.add(new EntityAclEntry(entityId, allowed)); } - return JacksonUtil.toString(result); + return new EntityAclEntry(entityId, allowed); } private void reply(LocalRequestMetaData rpcRequest, TbMsg response) { diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index ddd0907396..20bbc3939c 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -117,11 +117,11 @@ server: response_timeout: "${DEFAULT_RULE_ENGINE_RESPONSE_TIMEOUT:10000}" # Rule Engine REST API v2 settings (see /api/rule-engine/v2) -rule-engine: +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}" + max_entities: "${RULE_ENGINE_ACL_MAX_ENTITIES:20}" # Application info parameters app: 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 090bc3ab29..24e0288132 100644 --- a/application/src/test/java/org/thingsboard/server/controller/RuleEngineControllerV2EnrichmentTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/RuleEngineControllerV2EnrichmentTest.java @@ -20,6 +20,7 @@ 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; @@ -32,6 +33,7 @@ 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; @@ -46,18 +48,24 @@ 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(); @@ -152,20 +160,33 @@ public class RuleEngineControllerV2EnrichmentTest extends AbstractControllerTest } @Test - public void testV2EmptyAclEntitiesProducesEmptyAcl() throws Exception { + public void testV2NullAclEntitiesProducesEmptyAcl() throws Exception { loginTenantAdmin(); RuleEngineV2Request request = baseRequest(); // aclEntities left null TbMsg captured = doRequestAndCapture(request, tenantId); - assertThat(captured.getMetaData().getValue(TbMsgMetaData.TB_ACL_KEY)).isEqualTo("[]"); + assertThat(captured.getMetaData().getValue(TbMsgMetaData.TB_ACL_SNAPSHOT_KEY)).isEqualTo("[]"); assertThat(captured.getMetaData().getValue(TbMsgMetaData.TB_USER_ID_KEY)) .isEqualTo(tenantAdminUserId.getId().toString()); } @Test - public void testV2DuplicateEntitiesPreservedInOutput() throws Exception { + public void testV2EmptyAclEntitiesListProducesEmptyAcl() throws Exception { + loginTenantAdmin(); + + RuleEngineV2Request request = baseRequest(); + 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)) + .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"); @@ -180,13 +201,18 @@ public class RuleEngineControllerV2EnrichmentTest extends AbstractControllerTest 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 < 21; i++) { + for (int i = 0; i < MAX_ACL_ENTITIES + 1; i++) { tooMany.add(new DeviceId(UUID.randomUUID())); } @@ -235,14 +261,14 @@ public class RuleEngineControllerV2EnrichmentTest extends AbstractControllerTest Device device = createDevice("dev-inj", "tok-inj"); RuleEngineV2Request request = baseRequest(); - request.setPayload(JacksonUtil.toJsonNode("{\"" + TbMsgMetaData.TB_ACL_KEY + "\":\"attack\",\"" + + request.setPayload(JacksonUtil.toJsonNode("{\"" + TbMsgMetaData.TB_ACL_SNAPSHOT_KEY + "\":\"attack\",\"" + TbMsgMetaData.TB_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_KEY)).contains("\"entityType\":\"DEVICE\""); + assertThat(captured.getMetaData().getValue(TbMsgMetaData.TB_ACL_SNAPSHOT_KEY)).contains("\"entityType\":\"DEVICE\""); assertThat(captured.getMetaData().getValue(TbMsgMetaData.TB_USER_ID_KEY)) .isEqualTo(tenantAdminUserId.getId().toString()); } @@ -298,7 +324,7 @@ public class RuleEngineControllerV2EnrichmentTest extends AbstractControllerTest } private List parseAcl(TbMsg msg) { - String acl = msg.getMetaData().getValue(TbMsgMetaData.TB_ACL_KEY); + String acl = msg.getMetaData().getValue(TbMsgMetaData.TB_ACL_SNAPSHOT_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 a3c533f206..83ce4653c3 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,7 +35,7 @@ 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_KEY = "tb_aclSnapshot"; + public static final String TB_ACL_SNAPSHOT_KEY = "tb_aclSnapshot"; /** * Reserved metadata key. Populated by the platform with the UUID (as string) of From 8c491199acb33a2b9c96904841412933f0b5dc96 Mon Sep 17 00:00:00 2001 From: Oleksandra Matviienko Date: Tue, 12 May 2026 18:42:55 +0200 Subject: [PATCH 08/10] Fixed loginDifferentCustomer helper second-invocation login Second invocation tried to log in with savedDifferentCustomer.getEmail() (the Customer entity's email, typically null) and CUSTOMER_USER_PASSWORD. Replaced with the actual user constants DIFFERENT_CUSTOMER_USER_EMAIL and DIFFERENT_CUSTOMER_USER_PASSWORD. Removed the direct-login workaround in RuleEngineControllerV2EnrichmentTest that exercised the second-invocation path; the test now uses the helper. --- .../org/thingsboard/server/controller/AbstractWebTest.java | 2 +- .../controller/RuleEngineControllerV2EnrichmentTest.java | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) 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 index 24e0288132..ecf6e9bf09 100644 --- a/application/src/test/java/org/thingsboard/server/controller/RuleEngineControllerV2EnrichmentTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/RuleEngineControllerV2EnrichmentTest.java @@ -145,9 +145,8 @@ public class RuleEngineControllerV2EnrichmentTest extends AbstractControllerTest 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() helper has a bug on its - // second invocation (uses the wrong password constant), so we log in directly. - login(DIFFERENT_CUSTOMER_USER_EMAIL, "diffcustomer"); + // Customer 2 (foreign A, own B). + loginDifferentCustomer(); RuleEngineV2Request req2 = baseRequest(); req2.setAclEntities(List.of(deviceA.getId(), deviceB.getId())); TbMsg captured2 = doRequestAndCapture(req2, tenantId); From 3d77dd161d546b494f1d460ec85f541244f12c2b Mon Sep 17 00:00:00 2001 From: Oleksandra Matviienko Date: Tue, 12 May 2026 18:52:09 +0200 Subject: [PATCH 09/10] Added handleRuleEngineRequestV2 to RestClient Java REST client convenience method for the new POST /api/rule-engine/v2 endpoint. Mirrors the existing v1 handleRuleEngineRequest variants and accepts a RuleEngineV2Request DTO directly. --- .../java/org/thingsboard/rest/client/RestClient.java | 10 ++++++++++ 1 file changed, 10 insertions(+) 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(); } From 66aeb310c58b1f32cecf71ee1c1d0d70c24f2245 Mon Sep 17 00:00:00 2001 From: Oleksandra Matviienko Date: Wed, 13 May 2026 09:28:06 +0200 Subject: [PATCH 10/10] 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;