From 37d6c5dabd45a7cc4759ce02b79969e959017fdc Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Mon, 29 Sep 2025 13:07:57 +0300 Subject: [PATCH 01/56] Introduce API-key auth --- .../ThingsboardSecurityConfiguration.java | 50 +++- .../server/controller/ApiKeyController.java | 145 +++++++++++ .../server/controller/BaseController.java | 19 +- .../controller/ControllerConstants.java | 1 + .../controller/QrCodeSettingsController.java | 5 +- .../auth/DefaultTokenOutdatingService.java | 11 +- .../AbstractHeaderTokenExtractor.java} | 18 +- .../extractor/ApiKeyHeaderTokenExtractor.java | 31 +++ .../extractor/JwtHeaderTokenExtractor.java | 29 +++ .../extractor/JwtQueryTokenExtractor.java | 5 +- .../{jwt => }/extractor/TokenExtractor.java | 6 +- .../auth/jwt/JwtAuthenticationProvider.java | 3 +- ...wtTokenAuthenticationProcessingFilter.java | 23 +- .../RefreshTokenAuthenticationProvider.java | 14 +- .../jwt/RefreshTokenProcessingFilter.java | 12 +- .../auth/jwt/RefreshTokenRequest.java | 6 +- .../auth/jwt/SkipPathRequestMatcher.java | 10 +- .../settings/DefaultJwtSettingsValidator.java | 2 +- .../settings/InstallJwtSettingsValidator.java | 1 - .../jwt/settings/JwtSettingsValidator.java | 1 + .../pat/ApiKeyAuthenticationProvider.java | 93 +++++++ .../auth/pat/ApiKeyAuthenticationToken.java | 61 +++++ ...eyTokenAuthenticationProcessingFilter.java | 87 +++++++ ...RestAwareAuthenticationSuccessHandler.java | 3 +- .../security/model/token/AccessJwtToken.java | 11 +- .../security/model/token/JwtTokenFactory.java | 2 +- .../model/token/OAuth2AppTokenFactory.java | 5 +- .../model/token/RawAccessJwtToken.java | 14 +- .../security/model/token/RawApiKeyToken.java | 20 ++ .../DefaultAccessControlService.java | 5 +- .../service/security/permission/Resource.java | 3 +- .../permission/TenantAdminPermissions.java | 18 ++ .../src/main/resources/thingsboard.yml | 3 + .../controller/ApiKeyControllerTest.java | 144 +++++++++++ .../security/auth/JwtTokenFactoryTest.java | 14 +- .../security/auth/TokenOutdatingTest.java | 7 +- .../pat/ApiKeyAuthenticationProviderTest.java | 189 ++++++++++++++ .../server/dao/pat/ApiKeyService.java | 43 ++++ .../server/common/data/CacheConstants.java | 1 + .../server/common/data/EntityType.java | 9 +- .../server/common/data/StringUtils.java | 20 +- .../server/common/data/id/ApiKeyId.java | 46 ++++ .../common/data/id/EntityIdFactory.java | 1 + .../server/common/data/pat/ApiKey.java | 63 +++++ .../server/common/data/pat/ApiKeyInfo.java | 82 ++++++ .../common/data/security/model/JwtToken.java | 4 +- common/proto/src/main/proto/queue.proto | 1 + .../server/dao/model/ModelConstants.java | 11 + .../model/sql/AbstractApiKeyInfoEntity.java | 81 ++++++ .../server/dao/model/sql/ApiKeyEntity.java | 51 ++++ .../dao/model/sql/ApiKeyInfoEntity.java | 46 ++++ .../server/dao/pat/ApiKeyCacheKey.java | 40 +++ .../server/dao/pat/ApiKeyCaffeineCache.java | 33 +++ .../thingsboard/server/dao/pat/ApiKeyDao.java | 33 +++ .../server/dao/pat/ApiKeyEvictEvent.java | 19 ++ .../server/dao/pat/ApiKeyInfoDao.java | 29 +++ .../server/dao/pat/ApiKeyRedisCache.java | 36 +++ .../server/dao/pat/ApiKeyServiceImpl.java | 159 ++++++++++++ .../validator/ApiKeyDataValidator.java | 77 ++++++ .../dao/sql/pat/ApiKeyInfoRepository.java | 34 +++ .../server/dao/sql/pat/ApiKeyRepository.java | 53 ++++ .../server/dao/sql/pat/JpaApiKeyDao.java | 73 ++++++ .../server/dao/sql/pat/JpaApiKeyInfoDao.java | 58 +++++ .../server/dao/tenant/TenantServiceImpl.java | 2 +- .../server/dao/user/UserServiceImpl.java | 4 + .../resources/sql/schema-entities-idx.sql | 6 +- .../main/resources/sql/schema-entities.sql | 12 + .../server/dao/service/ApiKeyServiceTest.java | 234 ++++++++++++++++++ .../resources/application-test.properties | 3 + .../thingsboard/rest/client/RestClient.java | 52 ++-- 70 files changed, 2341 insertions(+), 146 deletions(-) create mode 100644 application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java rename application/src/main/java/org/thingsboard/server/service/security/auth/{jwt/extractor/JwtHeaderTokenExtractor.java => extractor/AbstractHeaderTokenExtractor.java} (78%) create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/extractor/ApiKeyHeaderTokenExtractor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/extractor/JwtHeaderTokenExtractor.java rename application/src/main/java/org/thingsboard/server/service/security/auth/{jwt => }/extractor/JwtQueryTokenExtractor.java (93%) rename application/src/main/java/org/thingsboard/server/service/security/auth/{jwt => }/extractor/TokenExtractor.java (91%) create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProvider.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationToken.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyTokenAuthenticationProcessingFilter.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/model/token/RawApiKeyToken.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/ApiKeyControllerTest.java create mode 100644 application/src/test/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProviderTest.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/pat/ApiKeyService.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/ApiKeyId.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/pat/ApiKey.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/pat/ApiKeyInfo.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractApiKeyInfoEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/ApiKeyEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/ApiKeyInfoEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyCacheKey.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyCaffeineCache.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyEvictEvent.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyInfoDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyRedisCache.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyServiceImpl.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/service/validator/ApiKeyDataValidator.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyInfoRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/pat/JpaApiKeyDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/pat/JpaApiKeyInfoDao.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/ApiKeyServiceTest.java diff --git a/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java b/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java index ca741ea8c6..d4e6bf2f8f 100644 --- a/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java +++ b/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java @@ -31,7 +31,6 @@ import org.springframework.security.config.annotation.method.configuration.Enabl import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; -import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; import org.springframework.security.config.annotation.web.configurers.RequestCacheConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver; @@ -48,13 +47,15 @@ import org.thingsboard.server.dao.oauth2.OAuth2Configuration; import org.thingsboard.server.exception.ThingsboardErrorResponseHandler; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.security.auth.AuthExceptionHandler; +import org.thingsboard.server.service.security.auth.extractor.TokenExtractor; import org.thingsboard.server.service.security.auth.jwt.JwtAuthenticationProvider; import org.thingsboard.server.service.security.auth.jwt.JwtTokenAuthenticationProcessingFilter; import org.thingsboard.server.service.security.auth.jwt.RefreshTokenAuthenticationProvider; import org.thingsboard.server.service.security.auth.jwt.RefreshTokenProcessingFilter; import org.thingsboard.server.service.security.auth.jwt.SkipPathRequestMatcher; -import org.thingsboard.server.service.security.auth.jwt.extractor.TokenExtractor; import org.thingsboard.server.service.security.auth.oauth2.HttpCookieOAuth2AuthorizationRequestRepository; +import org.thingsboard.server.service.security.auth.pat.ApiKeyAuthenticationProvider; +import org.thingsboard.server.service.security.auth.pat.ApiKeyTokenAuthenticationProcessingFilter; import org.thingsboard.server.service.security.auth.rest.RestAuthenticationProvider; import org.thingsboard.server.service.security.auth.rest.RestLoginProcessingFilter; import org.thingsboard.server.service.security.auth.rest.RestPublicLoginProcessingFilter; @@ -75,6 +76,9 @@ public class ThingsboardSecurityConfiguration { public static final String JWT_TOKEN_HEADER_PARAM_V2 = "Authorization"; public static final String JWT_TOKEN_QUERY_PARAM = "token"; + public static final String API_KEY_HEADER_PREFIX = "ApiKey "; + public static final String BEARER_HEADER_PREFIX = "Bearer "; + public static final String DEVICE_API_ENTRY_POINT = "/api/v1/**"; public static final String FORM_BASED_LOGIN_ENTRY_POINT = "/api/auth/login"; public static final String PUBLIC_LOGIN_ENTRY_POINT = "/api/auth/login/public"; @@ -116,6 +120,8 @@ public class ThingsboardSecurityConfiguration { private JwtAuthenticationProvider jwtAuthenticationProvider; @Autowired private RefreshTokenAuthenticationProvider refreshTokenAuthenticationProvider; + @Autowired + private ApiKeyAuthenticationProvider apiKeyAuthenticationProvider; @Autowired(required = false) OAuth2Configuration oauth2Configuration; @@ -124,6 +130,10 @@ public class ThingsboardSecurityConfiguration { @Qualifier("jwtHeaderTokenExtractor") private TokenExtractor jwtHeaderTokenExtractor; + @Autowired + @Qualifier("apiKeyHeaderTokenExtractor") + private TokenExtractor apiKeyHeaderTokenExtractor; + @Autowired private AuthenticationManager authenticationManager; @@ -139,7 +149,7 @@ public class ThingsboardSecurityConfiguration { } @Bean - protected FilterRegistrationBean buildEtagFilter() throws Exception { + protected FilterRegistrationBean buildEtagFilter() { ShallowEtagHeaderFilter etagFilter = new ShallowEtagHeaderFilter(); etagFilter.setWriteWeakETag(true); FilterRegistrationBean filterRegistrationBean @@ -150,25 +160,22 @@ public class ThingsboardSecurityConfiguration { } @Bean - protected RestLoginProcessingFilter buildRestLoginProcessingFilter() throws Exception { + protected RestLoginProcessingFilter buildRestLoginProcessingFilter() { RestLoginProcessingFilter filter = new RestLoginProcessingFilter(FORM_BASED_LOGIN_ENTRY_POINT, successHandler, failureHandler); filter.setAuthenticationManager(this.authenticationManager); return filter; } @Bean - protected RestPublicLoginProcessingFilter buildRestPublicLoginProcessingFilter() throws Exception { + protected RestPublicLoginProcessingFilter buildRestPublicLoginProcessingFilter() { RestPublicLoginProcessingFilter filter = new RestPublicLoginProcessingFilter(PUBLIC_LOGIN_ENTRY_POINT, successHandler, failureHandler); filter.setAuthenticationManager(this.authenticationManager); return filter; } - protected JwtTokenAuthenticationProcessingFilter buildJwtTokenAuthenticationProcessingFilter() throws Exception { - List pathsToSkip = new ArrayList<>(Arrays.asList(NON_TOKEN_BASED_AUTH_ENTRY_POINTS)); - pathsToSkip.addAll(Arrays.asList(WS_ENTRY_POINT, TOKEN_REFRESH_ENTRY_POINT, FORM_BASED_LOGIN_ENTRY_POINT, - PUBLIC_LOGIN_ENTRY_POINT, DEVICE_API_ENTRY_POINT, MAIL_OAUTH2_PROCESSING_ENTRY_POINT, - DEVICE_CONNECTIVITY_CERTIFICATE_DOWNLOAD_ENTRY_POINT)); - SkipPathRequestMatcher matcher = new SkipPathRequestMatcher(pathsToSkip, TOKEN_BASED_AUTH_ENTRY_POINT); + @Bean + protected JwtTokenAuthenticationProcessingFilter buildJwtTokenAuthenticationProcessingFilter() { + SkipPathRequestMatcher matcher = buildSkipPathRequestMatcher(); JwtTokenAuthenticationProcessingFilter filter = new JwtTokenAuthenticationProcessingFilter(failureHandler, jwtHeaderTokenExtractor, matcher); filter.setAuthenticationManager(this.authenticationManager); @@ -176,7 +183,24 @@ public class ThingsboardSecurityConfiguration { } @Bean - protected RefreshTokenProcessingFilter buildRefreshTokenProcessingFilter() throws Exception { + protected ApiKeyTokenAuthenticationProcessingFilter buildApiKeyTokenAuthenticationProcessingFilter() { + SkipPathRequestMatcher matcher = buildSkipPathRequestMatcher(); + ApiKeyTokenAuthenticationProcessingFilter filter = + new ApiKeyTokenAuthenticationProcessingFilter(failureHandler, apiKeyHeaderTokenExtractor, matcher); + filter.setAuthenticationManager(this.authenticationManager); + return filter; + } + + private SkipPathRequestMatcher buildSkipPathRequestMatcher() { + List pathsToSkip = new ArrayList<>(Arrays.asList(NON_TOKEN_BASED_AUTH_ENTRY_POINTS)); + pathsToSkip.addAll(Arrays.asList(WS_ENTRY_POINT, TOKEN_REFRESH_ENTRY_POINT, FORM_BASED_LOGIN_ENTRY_POINT, + PUBLIC_LOGIN_ENTRY_POINT, DEVICE_API_ENTRY_POINT, MAIL_OAUTH2_PROCESSING_ENTRY_POINT, + DEVICE_CONNECTIVITY_CERTIFICATE_DOWNLOAD_ENTRY_POINT)); + return new SkipPathRequestMatcher(pathsToSkip, TOKEN_BASED_AUTH_ENTRY_POINT); + } + + @Bean + protected RefreshTokenProcessingFilter buildRefreshTokenProcessingFilter() { RefreshTokenProcessingFilter filter = new RefreshTokenProcessingFilter(TOKEN_REFRESH_ENTRY_POINT, successHandler, failureHandler); filter.setAuthenticationManager(this.authenticationManager); return filter; @@ -187,6 +211,7 @@ public class ThingsboardSecurityConfiguration { return new ProviderManager(List.of( restAuthenticationProvider, jwtAuthenticationProvider, + apiKeyAuthenticationProvider, refreshTokenAuthenticationProvider )); } @@ -233,6 +258,7 @@ public class ThingsboardSecurityConfiguration { .addFilterBefore(buildRestLoginProcessingFilter(), UsernamePasswordAuthenticationFilter.class) .addFilterBefore(buildRestPublicLoginProcessingFilter(), UsernamePasswordAuthenticationFilter.class) .addFilterBefore(buildJwtTokenAuthenticationProcessingFilter(), UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(buildApiKeyTokenAuthenticationProcessingFilter(), UsernamePasswordAuthenticationFilter.class) .addFilterBefore(buildRefreshTokenProcessingFilter(), UsernamePasswordAuthenticationFilter.class) .addFilterBefore(payloadSizeFilter(), UsernamePasswordAuthenticationFilter.class) .addFilterAfter(rateLimitProcessingFilter, UsernamePasswordAuthenticationFilter.class) diff --git a/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java b/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java new file mode 100644 index 0000000000..b0afa23dcd --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java @@ -0,0 +1,145 @@ +/** + * Copyright © 2016-2025 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 io.swagger.v3.oas.annotations.Parameter; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.ApiKeyId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.pat.ApiKey; +import org.thingsboard.server.common.data.pat.ApiKeyInfo; +import org.thingsboard.server.config.annotations.ApiOperation; +import org.thingsboard.server.dao.pat.ApiKeyService; +import org.thingsboard.server.queue.util.TbCoreComponent; +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.Optional; +import java.util.UUID; + +import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.API_KEY_HEADER_PREFIX; +import static org.thingsboard.server.controller.ControllerConstants.API_KEY_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.USER_ID_PARAM_DESCRIPTION; + +@RestController +@TbCoreComponent +@Slf4j +@RequestMapping("/api") +@RequiredArgsConstructor +public class ApiKeyController extends BaseController { + + private final ApiKeyService apiKeyService; + + @ApiOperation(value = "Save API key for user (saveApiKey)", + notes = "Creates an API key for the given user and returns the token ONCE as 'ApiKey '." + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @PostMapping(value = "/apiKey") + public String saveApiKey( + @Parameter(description = "A JSON value representing the Api Key token.") + @RequestBody @Valid ApiKeyInfo apiKeyInfo) throws ThingsboardException { + SecurityUser securityUser = getCurrentUser(); + apiKeyInfo.setTenantId(securityUser.getTenantId()); + apiKeyInfo.setUserId(securityUser.getId()); + checkEntity(apiKeyInfo.getId(), apiKeyInfo, Resource.API_KEY); + return toUserApiKey(checkNotNull(apiKeyService.saveApiKey(securityUser.getTenantId(), apiKeyInfo)).getHash()); + } + + @ApiOperation(value = "Get User Api Keys (getUserApiKeys)", + notes = "Returns a page of api keys owned by user. " + + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping(value = "/apiKeys/{userId}") + public PageData getUserApiKeys( + @Parameter(description = USER_ID_PARAM_DESCRIPTION) + @PathVariable("userId") String userIdStr, + @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page) throws ThingsboardException { + SecurityUser securityUser = getCurrentUser(); + PageLink pageLink = createPageLink(pageSize, page, null, null, null); + UserId userId = new UserId(toUUID(userIdStr)); + accessControlService.checkPermission(securityUser, Resource.API_KEY, Operation.READ); + return apiKeyService.findApiKeysByUserId(securityUser.getTenantId(), userId, pageLink); + } + + @ApiOperation(value = "Update API key Description", + notes = "Updates the description of the existing API key by apiKeyId. " + + "Only the description can be updated. " + + "Referencing a non-existing ApiKey Id will cause a 'Not Found' error." + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @PutMapping("/apiKey/{id}/description") + public ApiKeyInfo updateApiKeyDescription( + @Parameter(description = API_KEY_ID_PARAM_DESCRIPTION, required = true) + @PathVariable UUID id, + @Parameter(description = "New description for the API key", example = "Description") + @RequestBody Optional description) throws Exception { + ApiKeyId apiKeyId = new ApiKeyId(id); + ApiKey apiKey = checkApiKeyId(apiKeyId, Operation.WRITE); + apiKey.setDescription(description.orElse(null)); + return apiKeyService.saveApiKey(apiKey.getTenantId(), apiKey); + } + + @ApiOperation(value = "Enable or disable API key (enableApiKey)", + notes = "Updates api key with enabled = true/false. " + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @PutMapping(value = "/apiKey/{id}/enabled/{enabledValue}") + public ApiKeyInfo enableApiKey( + @Parameter(description = "Unique identifier of the API key to enable/disable", required = true) + @PathVariable UUID id, + @Parameter(description = "Enabled or disabled api key", required = true) + @PathVariable(value = "enabledValue") Boolean enabledValue) throws ThingsboardException { + ApiKeyId apiKeyId = new ApiKeyId(id); + ApiKey apiKey = checkApiKeyId(apiKeyId, Operation.WRITE); + apiKey.setEnabled(enabledValue); + return apiKeyService.saveApiKey(apiKey.getTenantId(), apiKey); + } + + @ApiOperation(value = "Delete API key by ID (deleteApiKey)", + notes = "Deletes the API key. Referencing non-existing ApiKey Id will cause an error." + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @DeleteMapping(value = "/apiKey/{id}") + public void deleteApiKey(@PathVariable UUID id) throws ThingsboardException { + ApiKeyId apiKeyId = new ApiKeyId(id); + ApiKey apiKey = checkApiKeyId(apiKeyId, Operation.DELETE); + apiKeyService.deleteApiKey(apiKey.getTenantId(), apiKey, false); + } + + private String toUserApiKey(String hash) { + return API_KEY_HEADER_PREFIX + hash; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/controller/BaseController.java b/application/src/main/java/org/thingsboard/server/controller/BaseController.java index 26f116b083..585783e5f0 100644 --- a/application/src/main/java/org/thingsboard/server/controller/BaseController.java +++ b/application/src/main/java/org/thingsboard/server/controller/BaseController.java @@ -80,6 +80,7 @@ import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.AiModelId; import org.thingsboard.server.common.data.id.AlarmCommentId; import org.thingsboard.server.common.data.id.AlarmId; +import org.thingsboard.server.common.data.id.ApiKeyId; import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.AssetProfileId; import org.thingsboard.server.common.data.id.CalculatedFieldId; @@ -118,6 +119,7 @@ import org.thingsboard.server.common.data.oauth2.OAuth2Client; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.page.SortOrder; import org.thingsboard.server.common.data.page.TimePageLink; +import org.thingsboard.server.common.data.pat.ApiKey; import org.thingsboard.server.common.data.plugin.ComponentDescriptor; import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.data.query.EntityDataSortOrder; @@ -158,6 +160,7 @@ import org.thingsboard.server.dao.notification.NotificationTargetService; import org.thingsboard.server.dao.oauth2.OAuth2ClientService; import org.thingsboard.server.dao.oauth2.OAuth2ConfigTemplateService; import org.thingsboard.server.dao.ota.OtaPackageService; +import org.thingsboard.server.dao.pat.ApiKeyService; import org.thingsboard.server.dao.queue.QueueService; import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.dao.resource.ResourceService; @@ -221,8 +224,6 @@ import static org.thingsboard.server.dao.service.Validator.validateId; @TbCoreComponent public abstract class BaseController { - protected static final String DASHBOARD_ID = "dashboardId"; - protected static final String HOME_DASHBOARD_ID = "homeDashboardId"; protected static final String HOME_DASHBOARD_HIDE_TOOLBAR = "homeDashboardHideToolbar"; @@ -389,6 +390,9 @@ public abstract class BaseController { @Autowired protected TbAiModelService tbAiModelService; + @Autowired + protected ApiKeyService apiKeyService; + @Value("${server.log_controller_error_stack_trace}") @Getter private boolean logControllerErrorStackTrace; @@ -648,6 +652,7 @@ public abstract class BaseController { case MOBILE_APP_BUNDLE -> checkMobileAppBundleId(new MobileAppBundleId(entityId.getId()), operation); case CALCULATED_FIELD -> checkCalculatedFieldId(new CalculatedFieldId(entityId.getId()), operation); case AI_MODEL -> checkAiModelId(new AiModelId(entityId.getId()), operation); + case API_KEY -> checkApiKeyId(new ApiKeyId(entityId.getId()), operation); default -> (HasId) checkEntityId(entityId, entitiesService::findEntityByTenantIdAndId, operation); }; } catch (Exception e) { @@ -657,7 +662,7 @@ public abstract class BaseController { protected & HasTenantId, I extends EntityId> E checkEntityId(I entityId, ThrowingBiFunction findingFunction, Operation operation) throws ThingsboardException { try { - validateId((UUIDBased) entityId, "Invalid entity id"); + validateId((UUIDBased) entityId, id -> "Invalid entity id"); SecurityUser user = getCurrentUser(); E entity = findingFunction.apply(user.getTenantId(), entityId); checkNotNull(entity, entityId.getEntityType().getNormalName() + " with id [" + entityId + "] is not found"); @@ -855,12 +860,16 @@ public abstract class BaseController { return checkEntityId(settingsId, (tenantId, id) -> aiModelService.findAiModelByTenantIdAndId(tenantId, id).orElse(null), operation); } + ApiKey checkApiKeyId(ApiKeyId apiKeyId, Operation operation) throws ThingsboardException { + return checkEntityId(apiKeyId, apiKeyService::findApiKeyById, operation); + } + protected I emptyId(EntityType entityType) { return (I) EntityIdFactory.getByTypeAndUuid(entityType, ModelConstants.NULL_UUID); } public static Exception toException(Throwable error) { - return error != null ? (Exception.class.isInstance(error) ? (Exception) error : new Exception(error)) : null; + return error != null ? (error instanceof Exception ? (Exception) error : new Exception(error)) : null; } protected > void logEntityAction(SecurityUser user, EntityType entityType, E savedEntity, ActionType actionType) { @@ -939,7 +948,7 @@ public abstract class BaseController { } private CalculatedField checkCalculatedFieldId(CalculatedFieldId calculatedFieldId, Operation operation) throws ThingsboardException { - validateId(calculatedFieldId, "Invalid entity id"); + validateId(calculatedFieldId, id -> "Invalid entity id"); SecurityUser user = getCurrentUser(); CalculatedField cf = calculatedFieldService.findById(user.getTenantId(), calculatedFieldId); checkNotNull(cf, calculatedFieldId.getEntityType().getNormalName() + " with id [" + calculatedFieldId + "] is not found"); diff --git a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java index a87864726b..2c94b3da82 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java +++ b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java @@ -64,6 +64,7 @@ public class ControllerConstants { protected static final String WIDGET_TYPE_ID_PARAM_DESCRIPTION = "A string value representing the widget type id. For example, '784f394c-42b6-435a-983c-b7beff2784f9'"; protected static final String VC_REQUEST_ID_PARAM_DESCRIPTION = "A string value representing the version control request id. For example, '784f394c-42b6-435a-983c-b7beff2784f9'"; protected static final String RESOURCE_ID_PARAM_DESCRIPTION = "A string value representing the resource id. For example, '784f394c-42b6-435a-983c-b7beff2784f9'"; + protected static final String API_KEY_ID_PARAM_DESCRIPTION = "A string value representing the api key id. For example, '784f394c-42b6-435a-983c-b7beff2784f9'"; protected static final String SYSTEM_AUTHORITY_PARAGRAPH = "\n\nAvailable for users with 'SYS_ADMIN' authority."; protected static final String SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH = "\n\nAvailable for users with 'SYS_ADMIN' or 'TENANT_ADMIN' authority."; protected static final String TENANT_AUTHORITY_PARAGRAPH = "\n\nAvailable for users with 'TENANT_ADMIN' authority."; diff --git a/application/src/main/java/org/thingsboard/server/controller/QrCodeSettingsController.java b/application/src/main/java/org/thingsboard/server/controller/QrCodeSettingsController.java index 293f435c4c..a11c75912b 100644 --- a/application/src/main/java/org/thingsboard/server/controller/QrCodeSettingsController.java +++ b/application/src/main/java/org/thingsboard/server/controller/QrCodeSettingsController.java @@ -31,15 +31,12 @@ import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RestController; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.exception.ThingsboardException; -import org.thingsboard.server.common.data.id.MobileAppBundleId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.mobile.app.MobileApp; -import org.thingsboard.server.common.data.mobile.qrCodeSettings.QrCodeSettings; import org.thingsboard.server.common.data.mobile.app.StoreInfo; -import org.thingsboard.server.common.data.oauth2.PlatformType; +import org.thingsboard.server.common.data.mobile.qrCodeSettings.QrCodeSettings; import org.thingsboard.server.common.data.security.model.JwtPair; import org.thingsboard.server.config.annotations.ApiOperation; -import org.thingsboard.server.dao.mobile.MobileAppService; import org.thingsboard.server.dao.mobile.QrCodeSettingService; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.mobile.secret.MobileAppSecretService; diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/DefaultTokenOutdatingService.java b/application/src/main/java/org/thingsboard/server/service/security/auth/DefaultTokenOutdatingService.java index 7d09df9972..fd827fe989 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/DefaultTokenOutdatingService.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/DefaultTokenOutdatingService.java @@ -49,21 +49,22 @@ public class DefaultTokenOutdatingService implements TokenOutdatingService { @Override public boolean isOutdated(String token, UserId userId) { - Claims claims = tokenFactory.parseTokenClaims(token).getBody(); + Claims claims = tokenFactory.parseTokenClaims(token).getPayload(); long issueTime = claims.getIssuedAt().getTime(); String sessionId = claims.get("sessionId", String.class); - if (isTokenOutdated(issueTime, userId.toString())){ - return true; + if (isTokenOutdated(issueTime, userId.toString())) { + return true; } else { - return sessionId != null && isTokenOutdated(issueTime, sessionId); + return sessionId != null && isTokenOutdated(issueTime, sessionId); } } private Boolean isTokenOutdated(long issueTime, String sessionId) { - return Optional.ofNullable(cache.get(sessionId)).map(outdatageTime -> isTokenOutdated(issueTime, outdatageTime.get())).orElse(false); + return Optional.ofNullable(cache.get(sessionId)).map(outdatedTime -> isTokenOutdated(issueTime, outdatedTime.get())).orElse(false); } private boolean isTokenOutdated(long issueTime, Long outdatageTime) { return MILLISECONDS.toSeconds(issueTime) < MILLISECONDS.toSeconds(outdatageTime); } + } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/extractor/JwtHeaderTokenExtractor.java b/application/src/main/java/org/thingsboard/server/service/security/auth/extractor/AbstractHeaderTokenExtractor.java similarity index 78% rename from application/src/main/java/org/thingsboard/server/service/security/auth/jwt/extractor/JwtHeaderTokenExtractor.java rename to application/src/main/java/org/thingsboard/server/service/security/auth/extractor/AbstractHeaderTokenExtractor.java index 633e5ab699..d8bd0834e1 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/extractor/JwtHeaderTokenExtractor.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/extractor/AbstractHeaderTokenExtractor.java @@ -13,17 +13,20 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.service.security.auth.jwt.extractor; +package org.thingsboard.server.service.security.auth.extractor; import jakarta.servlet.http.HttpServletRequest; import org.springframework.security.authentication.AuthenticationServiceException; -import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.config.ThingsboardSecurityConfiguration; -@Component(value="jwtHeaderTokenExtractor") -public class JwtHeaderTokenExtractor implements TokenExtractor { - public static final String HEADER_PREFIX = "Bearer "; +public abstract class AbstractHeaderTokenExtractor implements TokenExtractor { + + private final String headerPrefix; + + protected AbstractHeaderTokenExtractor(String headerPrefix) { + this.headerPrefix = headerPrefix; + } @Override public String extract(HttpServletRequest request) { @@ -35,10 +38,11 @@ public class JwtHeaderTokenExtractor implements TokenExtractor { } } - if (header.length() < HEADER_PREFIX.length()) { + if (header.length() < headerPrefix.length()) { throw new AuthenticationServiceException("Invalid authorization header size."); } - return header.substring(HEADER_PREFIX.length(), header.length()); + return header.substring(headerPrefix.length()); } + } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/extractor/ApiKeyHeaderTokenExtractor.java b/application/src/main/java/org/thingsboard/server/service/security/auth/extractor/ApiKeyHeaderTokenExtractor.java new file mode 100644 index 0000000000..9aec92b8c5 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/extractor/ApiKeyHeaderTokenExtractor.java @@ -0,0 +1,31 @@ +/** + * Copyright © 2016-2025 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.service.security.auth.extractor; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Component; + +import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.API_KEY_HEADER_PREFIX; + +@Component +@Qualifier("apiKeyHeaderTokenExtractor") +public class ApiKeyHeaderTokenExtractor extends AbstractHeaderTokenExtractor { + + public ApiKeyHeaderTokenExtractor() { + super(API_KEY_HEADER_PREFIX); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/extractor/JwtHeaderTokenExtractor.java b/application/src/main/java/org/thingsboard/server/service/security/auth/extractor/JwtHeaderTokenExtractor.java new file mode 100644 index 0000000000..4bee44bf51 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/extractor/JwtHeaderTokenExtractor.java @@ -0,0 +1,29 @@ +/** + * Copyright © 2016-2025 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.service.security.auth.extractor; + +import org.springframework.stereotype.Component; + +import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.BEARER_HEADER_PREFIX; + +@Component(value = "jwtHeaderTokenExtractor") +public class JwtHeaderTokenExtractor extends AbstractHeaderTokenExtractor { + + public JwtHeaderTokenExtractor() { + super(BEARER_HEADER_PREFIX); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/extractor/JwtQueryTokenExtractor.java b/application/src/main/java/org/thingsboard/server/service/security/auth/extractor/JwtQueryTokenExtractor.java similarity index 93% rename from application/src/main/java/org/thingsboard/server/service/security/auth/jwt/extractor/JwtQueryTokenExtractor.java rename to application/src/main/java/org/thingsboard/server/service/security/auth/extractor/JwtQueryTokenExtractor.java index 7cc02605aa..44fc8308d3 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/extractor/JwtQueryTokenExtractor.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/extractor/JwtQueryTokenExtractor.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.service.security.auth.jwt.extractor; +package org.thingsboard.server.service.security.auth.extractor; import jakarta.servlet.http.HttpServletRequest; import org.springframework.security.authentication.AuthenticationServiceException; @@ -21,7 +21,7 @@ import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.config.ThingsboardSecurityConfiguration; -@Component(value="jwtQueryTokenExtractor") +@Component(value = "jwtQueryTokenExtractor") public class JwtQueryTokenExtractor implements TokenExtractor { @Override @@ -39,4 +39,5 @@ public class JwtQueryTokenExtractor implements TokenExtractor { return token; } + } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/extractor/TokenExtractor.java b/application/src/main/java/org/thingsboard/server/service/security/auth/extractor/TokenExtractor.java similarity index 91% rename from application/src/main/java/org/thingsboard/server/service/security/auth/jwt/extractor/TokenExtractor.java rename to application/src/main/java/org/thingsboard/server/service/security/auth/extractor/TokenExtractor.java index 22766bff60..991ebe223e 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/extractor/TokenExtractor.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/extractor/TokenExtractor.java @@ -13,10 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.service.security.auth.jwt.extractor; +package org.thingsboard.server.service.security.auth.extractor; import jakarta.servlet.http.HttpServletRequest; public interface TokenExtractor { + String extract(HttpServletRequest request); -} \ No newline at end of file + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/JwtAuthenticationProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/JwtAuthenticationProvider.java index 1ba489546f..5344c15582 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/JwtAuthenticationProvider.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/JwtAuthenticationProvider.java @@ -39,7 +39,7 @@ public class JwtAuthenticationProvider implements AuthenticationProvider { @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { RawAccessJwtToken rawAccessToken = (RawAccessJwtToken) authentication.getCredentials(); - SecurityUser securityUser = authenticate(rawAccessToken.getToken()); + SecurityUser securityUser = authenticate(rawAccessToken.token()); return new JwtAuthenticationToken(securityUser); } @@ -58,4 +58,5 @@ public class JwtAuthenticationProvider implements AuthenticationProvider { public boolean supports(Class authentication) { return (JwtAuthenticationToken.class.isAssignableFrom(authentication)); } + } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/JwtTokenAuthenticationProcessingFilter.java b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/JwtTokenAuthenticationProcessingFilter.java index 9996c1eeab..cd7835b3be 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/JwtTokenAuthenticationProcessingFilter.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/JwtTokenAuthenticationProcessingFilter.java @@ -28,12 +28,17 @@ import org.springframework.security.web.authentication.AbstractAuthenticationPro import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.security.web.util.matcher.RequestMatcher; import org.thingsboard.server.service.security.auth.JwtAuthenticationToken; -import org.thingsboard.server.service.security.auth.jwt.extractor.TokenExtractor; +import org.thingsboard.server.service.security.auth.extractor.TokenExtractor; import org.thingsboard.server.service.security.model.token.RawAccessJwtToken; import java.io.IOException; +import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.BEARER_HEADER_PREFIX; +import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.JWT_TOKEN_HEADER_PARAM; +import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.JWT_TOKEN_HEADER_PARAM_V2; + public class JwtTokenAuthenticationProcessingFilter extends AbstractAuthenticationProcessingFilter { + private final AuthenticationFailureHandler failureHandler; private final TokenExtractor tokenExtractor; @@ -46,8 +51,7 @@ public class JwtTokenAuthenticationProcessingFilter extends AbstractAuthenticati } @Override - public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) - throws AuthenticationException, IOException, ServletException { + public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { RawAccessJwtToken token = new RawAccessJwtToken(tokenExtractor.extract(request)); return getAuthenticationManager().authenticate(new JwtAuthenticationToken(token)); } @@ -61,10 +65,23 @@ public class JwtTokenAuthenticationProcessingFilter extends AbstractAuthenticati chain.doFilter(request, response); } + @Override + protected boolean requiresAuthentication(HttpServletRequest request, HttpServletResponse response) { + if (!super.requiresAuthentication(request, response)) { + return false; + } + String header = request.getHeader(JWT_TOKEN_HEADER_PARAM); + if (header == null) { + header = request.getHeader(JWT_TOKEN_HEADER_PARAM_V2); + } + return header != null && header.startsWith(BEARER_HEADER_PREFIX); + } + @Override protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException { SecurityContextHolder.clearContext(); failureHandler.onAuthenticationFailure(request, response, failed); } + } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenAuthenticationProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenAuthenticationProvider.java index 36a6d9beb1..7c11bd879b 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenAuthenticationProvider.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenAuthenticationProvider.java @@ -57,17 +57,17 @@ public class RefreshTokenAuthenticationProvider implements AuthenticationProvide public Authentication authenticate(Authentication authentication) throws AuthenticationException { Assert.notNull(authentication, "No authentication data provided"); RawAccessJwtToken rawAccessToken = (RawAccessJwtToken) authentication.getCredentials(); - SecurityUser unsafeUser = tokenFactory.parseRefreshToken(rawAccessToken.getToken()); + SecurityUser unsafeUser = tokenFactory.parseRefreshToken(rawAccessToken.token()); UserPrincipal principal = unsafeUser.getUserPrincipal(); SecurityUser securityUser; - if (principal.getType() == UserPrincipal.Type.USER_NAME) { + if (principal.getType() == UserPrincipal.Type.USER_NAME) { securityUser = authenticateByUserId(unsafeUser.getId()); } else { securityUser = authenticateByPublicId(principal.getValue()); } securityUser.setSessionId(unsafeUser.getSessionId()); - if (tokenOutdatingService.isOutdated(rawAccessToken.getToken(), securityUser.getId())) { + if (tokenOutdatingService.isOutdated(rawAccessToken.token(), securityUser.getId())) { throw new CredentialsExpiredException("Token is outdated"); } @@ -93,9 +93,8 @@ public class RefreshTokenAuthenticationProvider implements AuthenticationProvide if (user.getAuthority() == null) throw new InsufficientAuthenticationException("User has no authority assigned"); UserPrincipal userPrincipal = new UserPrincipal(UserPrincipal.Type.USER_NAME, user.getEmail()); - SecurityUser securityUser = new SecurityUser(user, userCredentials.isEnabled(), userPrincipal); - return securityUser; + return new SecurityUser(user, userCredentials.isEnabled(), userPrincipal); } private SecurityUser authenticateByPublicId(String publicId) { @@ -125,13 +124,12 @@ public class RefreshTokenAuthenticationProvider implements AuthenticationProvide UserPrincipal userPrincipal = new UserPrincipal(UserPrincipal.Type.PUBLIC_ID, publicId); - SecurityUser securityUser = new SecurityUser(user, true, userPrincipal); - - return securityUser; + return new SecurityUser(user, true, userPrincipal); } @Override public boolean supports(Class authentication) { return (RefreshAuthenticationToken.class.isAssignableFrom(authentication)); } + } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenProcessingFilter.java b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenProcessingFilter.java index 1a4f637496..1800acf129 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenProcessingFilter.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenProcessingFilter.java @@ -51,11 +51,10 @@ public class RefreshTokenProcessingFilter extends AbstractAuthenticationProcessi } @Override - public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) - throws AuthenticationException, IOException, ServletException { + public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { if (!HttpMethod.POST.name().equals(request.getMethod())) { - if(log.isDebugEnabled()) { - log.debug("Authentication method not supported. Request method: " + request.getMethod()); + if (log.isDebugEnabled()) { + log.debug("Authentication method not supported. Request method: {}", request.getMethod()); } throw new AuthMethodNotSupportedException("Authentication method not supported"); } @@ -67,11 +66,11 @@ public class RefreshTokenProcessingFilter extends AbstractAuthenticationProcessi throw new AuthenticationServiceException("Invalid refresh token request payload"); } - if (StringUtils.isBlank(refreshTokenRequest.getRefreshToken())) { + if (refreshTokenRequest == null || StringUtils.isBlank(refreshTokenRequest.refreshToken())) { throw new AuthenticationServiceException("Refresh token is not provided"); } - RawAccessJwtToken token = new RawAccessJwtToken(refreshTokenRequest.getRefreshToken()); + RawAccessJwtToken token = new RawAccessJwtToken(refreshTokenRequest.refreshToken()); return this.getAuthenticationManager().authenticate(new RefreshAuthenticationToken(token)); } @@ -88,4 +87,5 @@ public class RefreshTokenProcessingFilter extends AbstractAuthenticationProcessi SecurityContextHolder.clearContext(); failureHandler.onAuthenticationFailure(request, response, failed); } + } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenRequest.java b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenRequest.java index 7c4d641234..9505578541 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenRequest.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenRequest.java @@ -18,15 +18,11 @@ package org.thingsboard.server.service.security.auth.jwt; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; -public class RefreshTokenRequest { - private String refreshToken; +public record RefreshTokenRequest(String refreshToken) { @JsonCreator public RefreshTokenRequest(@JsonProperty("refreshToken") String refreshToken) { this.refreshToken = refreshToken; } - public String getRefreshToken() { - return refreshToken; - } } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/SkipPathRequestMatcher.java b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/SkipPathRequestMatcher.java index 6b87a90edf..b58254651a 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/SkipPathRequestMatcher.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/SkipPathRequestMatcher.java @@ -25,12 +25,13 @@ import java.util.List; import java.util.stream.Collectors; public class SkipPathRequestMatcher implements RequestMatcher { - private OrRequestMatcher matchers; - private RequestMatcher processingMatcher; + + private final OrRequestMatcher matchers; + private final RequestMatcher processingMatcher; public SkipPathRequestMatcher(List pathsToSkip, String processingPath) { Assert.notNull(pathsToSkip, "List of paths to skip is required."); - List m = pathsToSkip.stream().map(path -> new AntPathRequestMatcher(path)).collect(Collectors.toList()); + List m = pathsToSkip.stream().map(AntPathRequestMatcher::new).collect(Collectors.toList()); matchers = new OrRequestMatcher(m); processingMatcher = new AntPathRequestMatcher(processingPath); } @@ -40,6 +41,7 @@ public class SkipPathRequestMatcher implements RequestMatcher { if (matchers.matches(request)) { return false; } - return processingMatcher.matches(request) ? true : false; + return processingMatcher.matches(request); } + } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/DefaultJwtSettingsValidator.java b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/DefaultJwtSettingsValidator.java index 573510ccb3..63f05f6255 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/DefaultJwtSettingsValidator.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/DefaultJwtSettingsValidator.java @@ -66,7 +66,7 @@ public class DefaultJwtSettingsValidator implements JwtSettingsValidator { throw new DataValidationException("JWT token signing key should be a Base64 encoded string representing at least 512 bits of data!"); } - System.arraycopy(decodedKey, 0, RandomUtils.nextBytes(decodedKey.length), 0, decodedKey.length); //secure memory + System.arraycopy(decodedKey, 0, RandomUtils.secure().randomBytes(decodedKey.length), 0, decodedKey.length); // secure memory } } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/InstallJwtSettingsValidator.java b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/InstallJwtSettingsValidator.java index e840415b4a..fdfd1a903d 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/InstallJwtSettingsValidator.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/InstallJwtSettingsValidator.java @@ -24,7 +24,6 @@ import org.thingsboard.server.common.data.security.model.JwtSettings; /** * During Install or upgrade the validation is suppressed to keep existing data * */ - @Primary @Profile("install") @Component diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/JwtSettingsValidator.java b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/JwtSettingsValidator.java index dbde1c30b1..efa38b149c 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/JwtSettingsValidator.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/JwtSettingsValidator.java @@ -20,4 +20,5 @@ import org.thingsboard.server.common.data.security.model.JwtSettings; public interface JwtSettingsValidator { void validate(JwtSettings jwtSettings); + } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProvider.java new file mode 100644 index 0000000000..6bc9c50a66 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProvider.java @@ -0,0 +1,93 @@ +/** + * Copyright © 2016-2025 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.service.security.auth.pat; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.DisabledException; +import org.springframework.security.authentication.InsufficientAuthenticationException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.pat.ApiKey; +import org.thingsboard.server.common.data.security.UserCredentials; +import org.thingsboard.server.dao.pat.ApiKeyService; +import org.thingsboard.server.dao.user.UserService; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.model.UserPrincipal; +import org.thingsboard.server.service.security.model.token.RawApiKeyToken; + +@Component +@RequiredArgsConstructor +public class ApiKeyAuthenticationProvider implements org.springframework.security.authentication.AuthenticationProvider { + + private final ApiKeyService apiKeyService; + private final UserService userService; + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + RawApiKeyToken raw = (RawApiKeyToken) authentication.getCredentials(); + SecurityUser securityUser = authenticate(raw.token()); + return new ApiKeyAuthenticationToken(securityUser); + } + + @Override + public boolean supports(Class authentication) { + return ApiKeyAuthenticationToken.class.isAssignableFrom(authentication); + } + + private SecurityUser authenticate(String key) { + if (StringUtils.isEmpty(key)) { + throw new BadCredentialsException("Empty API key"); + } + ApiKey apiKey = apiKeyService.findApiKeyByHash(key); + if (apiKey == null) { + throw new UsernameNotFoundException("User not found for the provided API key"); + } + if (!apiKey.isEnabled()) { + throw new DisabledException("API key auth is not active"); + } + if (apiKey.getExpirationTime() != 0 && apiKey.getExpirationTime() < System.currentTimeMillis()) { + throw new BadCredentialsException("API key is expired"); + } + TenantId tenantId = apiKey.getTenantId(); + UserId userId = apiKey.getUserId(); + User user = userService.findUserById(tenantId, userId); + if (user == null) { + throw new UsernameNotFoundException("User for the provided API key is no longer exists"); + } + UserCredentials userCredentials = userService.findUserCredentialsByUserId(tenantId, userId); + if (userCredentials == null) { + throw new UsernameNotFoundException("User credentials not found"); + } + if (!userCredentials.isEnabled()) { + throw new DisabledException("User is not active"); + } + if (user.getAuthority() == null) { + throw new InsufficientAuthenticationException("User has no authority assigned"); + } + + UserPrincipal userPrincipal = new UserPrincipal(UserPrincipal.Type.USER_NAME, user.getEmail()); + + return new SecurityUser(user, userCredentials.isEnabled(), userPrincipal); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationToken.java b/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationToken.java new file mode 100644 index 0000000000..c6f686f204 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationToken.java @@ -0,0 +1,61 @@ +/** + * Copyright © 2016-2025 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.service.security.auth.pat; + +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.model.token.RawApiKeyToken; + +import java.io.Serial; + +public class ApiKeyAuthenticationToken extends AbstractAuthenticationToken { + + @Serial + private static final long serialVersionUID = 2978710889397403536L; + + private RawApiKeyToken rawApiKeyToken; + private SecurityUser securityUser; + + public ApiKeyAuthenticationToken(RawApiKeyToken raw) { + super(null); + this.rawApiKeyToken = raw; + setAuthenticated(false); + } + + public ApiKeyAuthenticationToken(SecurityUser securityUser) { + super(securityUser.getAuthorities()); + this.eraseCredentials(); + this.securityUser = securityUser; + super.setAuthenticated(true); + } + + @Override + public Object getCredentials() { + return rawApiKeyToken; + } + + @Override + public Object getPrincipal() { + return this.securityUser; + } + + @Override + public void eraseCredentials() { + super.eraseCredentials(); + this.rawApiKeyToken = null; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyTokenAuthenticationProcessingFilter.java b/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyTokenAuthenticationProcessingFilter.java new file mode 100644 index 0000000000..b5f940f4cc --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyTokenAuthenticationProcessingFilter.java @@ -0,0 +1,87 @@ +/** + * Copyright © 2016-2025 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.service.security.auth.pat; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.thingsboard.server.service.security.auth.extractor.TokenExtractor; +import org.thingsboard.server.service.security.model.token.RawApiKeyToken; + +import java.io.IOException; + +import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.API_KEY_HEADER_PREFIX; +import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.JWT_TOKEN_HEADER_PARAM; +import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.JWT_TOKEN_HEADER_PARAM_V2; + +public class ApiKeyTokenAuthenticationProcessingFilter extends AbstractAuthenticationProcessingFilter { + + private final AuthenticationFailureHandler failureHandler; + private final TokenExtractor tokenExtractor; + + @Autowired + public ApiKeyTokenAuthenticationProcessingFilter(AuthenticationFailureHandler failureHandler, + @Qualifier("apiKeyHeaderTokenExtractor") TokenExtractor tokenExtractor, RequestMatcher matcher) { + super(matcher); + this.failureHandler = failureHandler; + this.tokenExtractor = tokenExtractor; + } + + @Override + public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { + RawApiKeyToken token = new RawApiKeyToken(tokenExtractor.extract(request)); + return getAuthenticationManager().authenticate(new ApiKeyAuthenticationToken(token)); + } + + @Override + protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, + Authentication authResult) throws IOException, ServletException { + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(authResult); + SecurityContextHolder.setContext(context); + chain.doFilter(request, response); + } + + @Override + protected boolean requiresAuthentication(HttpServletRequest request, HttpServletResponse response) { + if (!super.requiresAuthentication(request, response)) { + return false; + } + String header = request.getHeader(JWT_TOKEN_HEADER_PARAM); + if (header == null) { + header = request.getHeader(JWT_TOKEN_HEADER_PARAM_V2); + } + return header != null && header.startsWith(API_KEY_HEADER_PREFIX); + } + + @Override + protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, + AuthenticationException failed) throws IOException, ServletException { + SecurityContextHolder.clearContext(); + failureHandler.onAuthenticationFailure(request, response, failed); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationSuccessHandler.java b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationSuccessHandler.java index 14dceb7b25..0baa26f53f 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationSuccessHandler.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationSuccessHandler.java @@ -55,7 +55,7 @@ public class RestAwareAuthenticationSuccessHandler implements AuthenticationSucc .flatMap(settings -> Optional.ofNullable(settings.getTotalAllowedTimeForVerification()) .filter(time -> time > 0)) .orElse((int) TimeUnit.MINUTES.toSeconds(30)); - tokenPair.setToken(tokenFactory.createPreVerificationToken(securityUser, preVerificationTokenLifetime).getToken()); + tokenPair.setToken(tokenFactory.createPreVerificationToken(securityUser, preVerificationTokenLifetime).token()); tokenPair.setRefreshToken(null); tokenPair.setScope(Authority.PRE_VERIFICATION_TOKEN); } else { @@ -83,4 +83,5 @@ public class RestAwareAuthenticationSuccessHandler implements AuthenticationSucc session.removeAttribute(WebAttributes.AUTHENTICATION_EXCEPTION); } + } diff --git a/application/src/main/java/org/thingsboard/server/service/security/model/token/AccessJwtToken.java b/application/src/main/java/org/thingsboard/server/service/security/model/token/AccessJwtToken.java index 53b606f6f4..4c1b6610e3 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/model/token/AccessJwtToken.java +++ b/application/src/main/java/org/thingsboard/server/service/security/model/token/AccessJwtToken.java @@ -17,15 +17,6 @@ package org.thingsboard.server.service.security.model.token; import org.thingsboard.server.common.data.security.model.JwtToken; -public final class AccessJwtToken implements JwtToken { - private final String rawToken; - - public AccessJwtToken(String rawToken) { - this.rawToken = rawToken; - } - - public String getToken() { - return this.rawToken; - } +public record AccessJwtToken(String token) implements JwtToken { } diff --git a/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java b/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java index f9aa6db0f5..0b1a7cf82d 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java +++ b/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java @@ -235,7 +235,7 @@ public class JwtTokenFactory { securityUser.setSessionId(UUID.randomUUID().toString()); JwtToken accessToken = createAccessJwtToken(securityUser); JwtToken refreshToken = createRefreshToken(securityUser); - return new JwtPair(accessToken.getToken(), refreshToken.getToken()); + return new JwtPair(accessToken.token(), refreshToken.token()); } private SecretKey getSecretKey(boolean forceReload) { diff --git a/application/src/main/java/org/thingsboard/server/service/security/model/token/OAuth2AppTokenFactory.java b/application/src/main/java/org/thingsboard/server/service/security/model/token/OAuth2AppTokenFactory.java index c8ea5232a0..7a7beb64a2 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/model/token/OAuth2AppTokenFactory.java +++ b/application/src/main/java/org/thingsboard/server/service/security/model/token/OAuth2AppTokenFactory.java @@ -20,9 +20,9 @@ import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.Jws; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.MalformedJwtException; -import io.jsonwebtoken.SignatureException; import io.jsonwebtoken.UnsupportedJwtException; import io.jsonwebtoken.security.Keys; +import io.jsonwebtoken.security.SignatureException; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.StringUtils; @@ -43,8 +43,7 @@ public class OAuth2AppTokenFactory { Jws jwsClaims; try { jwsClaims = Jwts.parser().verifyWith(Keys.hmacShaKeyFor(Base64.getDecoder().decode(appSecret))).build().parseSignedClaims(appToken); - } - catch (UnsupportedJwtException | MalformedJwtException | IllegalArgumentException | SignatureException ex) { + } catch (UnsupportedJwtException | MalformedJwtException | IllegalArgumentException | SignatureException ex) { throw new IllegalArgumentException("Invalid Application token: ", ex); } catch (ExpiredJwtException expiredEx) { throw new IllegalArgumentException("Application token expired", expiredEx); diff --git a/application/src/main/java/org/thingsboard/server/service/security/model/token/RawAccessJwtToken.java b/application/src/main/java/org/thingsboard/server/service/security/model/token/RawAccessJwtToken.java index fd91fa9605..cd59697424 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/model/token/RawAccessJwtToken.java +++ b/application/src/main/java/org/thingsboard/server/service/security/model/token/RawAccessJwtToken.java @@ -19,18 +19,6 @@ import org.thingsboard.server.common.data.security.model.JwtToken; import java.io.Serializable; -public class RawAccessJwtToken implements JwtToken, Serializable { +public record RawAccessJwtToken(String token) implements JwtToken, Serializable { - private static final long serialVersionUID = -797397445703066079L; - - private String token; - - public RawAccessJwtToken(String token) { - this.token = token; - } - - @Override - public String getToken() { - return token; - } } diff --git a/application/src/main/java/org/thingsboard/server/service/security/model/token/RawApiKeyToken.java b/application/src/main/java/org/thingsboard/server/service/security/model/token/RawApiKeyToken.java new file mode 100644 index 0000000000..e361c48bc8 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/model/token/RawApiKeyToken.java @@ -0,0 +1,20 @@ +/** + * Copyright © 2016-2025 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.service.security.model.token; + +public record RawApiKeyToken(String token) { + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/DefaultAccessControlService.java b/application/src/main/java/org/thingsboard/server/service/security/permission/DefaultAccessControlService.java index a5feb1c502..f5d7b8856e 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/permission/DefaultAccessControlService.java +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/DefaultAccessControlService.java @@ -33,7 +33,6 @@ import java.util.Optional; @Slf4j public class DefaultAccessControlService implements AccessControlService { - private static final String INCORRECT_TENANT_ID = "Incorrect tenantId "; private static final String YOU_DON_T_HAVE_PERMISSION_TO_PERFORM_THIS_OPERATION = "You don't have permission to perform this operation!"; private final Map authorityPermissions = new HashMap<>(); @@ -58,7 +57,7 @@ public class DefaultAccessControlService implements AccessControlService { @Override @SuppressWarnings("unchecked") public void checkPermission(SecurityUser user, Resource resource, - Operation operation, I entityId, T entity) throws ThingsboardException { + Operation operation, I entityId, T entity) throws ThingsboardException { PermissionChecker permissionChecker = getPermissionChecker(user.getAuthority(), resource); if (!permissionChecker.hasPermission(user, operation, entityId, entity)) { permissionDenied(); @@ -71,7 +70,7 @@ public class DefaultAccessControlService implements AccessControlService { permissionDenied(); } Optional permissionChecker = permissions.getPermissionChecker(resource); - if (!permissionChecker.isPresent()) { + if (permissionChecker.isEmpty()) { permissionDenied(); } return permissionChecker.get(); diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java b/application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java index 8a4208c457..5fc7daecec 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java @@ -53,7 +53,8 @@ public enum Resource { EntityType.NOTIFICATION_REQUEST, EntityType.NOTIFICATION_RULE), MOBILE_APP_SETTINGS, JOB(EntityType.JOB), - AI_MODEL(EntityType.AI_MODEL); + AI_MODEL(EntityType.AI_MODEL), + API_KEY(EntityType.API_KEY); private final Set entityTypes; diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java b/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java index 7a824ca735..33514dcf99 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java @@ -20,8 +20,11 @@ import org.thingsboard.server.common.data.HasTenantId; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.ai.AiModel; import org.thingsboard.server.common.data.id.AiModelId; +import org.thingsboard.server.common.data.id.ApiKeyId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.pat.ApiKey; +import org.thingsboard.server.common.data.pat.ApiKeyInfo; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.service.security.model.SecurityUser; @@ -59,6 +62,7 @@ public class TenantAdminPermissions extends AbstractPermissions { put(Resource.MOBILE_APP_BUNDLE, tenantEntityPermissionChecker); put(Resource.JOB, tenantEntityPermissionChecker); put(Resource.AI_MODEL, aiModelPermissionChecker); + put(Resource.API_KEY, apiKeysPermissionChecker); } public static final PermissionChecker tenantEntityPermissionChecker = new PermissionChecker() { @@ -163,4 +167,18 @@ public class TenantAdminPermissions extends AbstractPermissions { }; + private static final PermissionChecker apiKeysPermissionChecker = new PermissionChecker<>() { + + @Override + public boolean hasPermission(SecurityUser user, Operation operation) { + return true; + } + + @Override + public boolean hasPermission(SecurityUser user, Operation operation, ApiKeyId entityId, ApiKeyInfo entity) { + return user.getTenantId().equals(entity.getTenantId()); + } + + }; + } diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 68cd479411..815a6c0026 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -662,6 +662,9 @@ cache: aiModel: timeToLiveInMinutes: "${CACHE_SPECS_AI_MODEL_TTL:1440}" # AI model cache TTL maxSize: "${CACHE_SPECS_AI_MODEL_MAX_SIZE:10000}" # 0 means the cache is disabled + apiKeys: + timeToLiveInMinutes: "${CACHE_SPECS_API_KEYS_TTL:1440}" # API keys cache TTL + maxSize: "${CACHE_SPECS_API_KEYS_MAX_SIZE:10000}" # 0 means the cache is disabled # Deliberately placed outside the 'specs' group above notificationRules: diff --git a/application/src/test/java/org/thingsboard/server/controller/ApiKeyControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/ApiKeyControllerTest.java new file mode 100644 index 0000000000..156ec5baa3 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/ApiKeyControllerTest.java @@ -0,0 +1,144 @@ +/** + * Copyright © 2016-2025 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 org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.pat.ApiKeyInfo; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.UUID; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.API_KEY_HEADER_PREFIX; + +@DaoSqlTest +public class ApiKeyControllerTest extends AbstractControllerTest { + + @Before + public void setUp() throws Exception { + loginTenantAdmin(); + } + + @Test + public void testSaveApiKey() throws Exception { + ApiKeyInfo apiKeyInfo = constructApiKeyInfo("New API key description", true); + + String apiKeyStr = doPost("/api/apiKey", apiKeyInfo, String.class); + Assert.assertTrue(apiKeyStr.startsWith(API_KEY_HEADER_PREFIX)); + + PageData pageData = doGetTypedWithPageLink("/api/apiKeys/" + tenantAdminUserId + "?", new TypeReference<>() {}, new PageLink(10, 0)); + Assert.assertEquals(1, pageData.getData().size()); + + ApiKeyInfo savedApiKey = pageData.getData().get(0); + Assert.assertNotNull(savedApiKey); + Assert.assertEquals(apiKeyInfo.getDescription(), savedApiKey.getDescription()); + Assert.assertEquals(apiKeyInfo.isEnabled(), savedApiKey.isEnabled()); + Assert.assertEquals(tenantId, savedApiKey.getTenantId()); + Assert.assertEquals(tenantAdminUser.getId(), savedApiKey.getUserId()); + + doDelete("/api/apiKey/" + savedApiKey.getId()).andExpect(status().isOk()); + } + + @Test + public void tesFindUserApiKeys() throws Exception { + PageData pageData = doGetTypedWithPageLink("/api/apiKeys/" + tenantAdminUserId + "?", new TypeReference<>() {}, new PageLink(10, 0)); + Assert.assertTrue(pageData.getData().isEmpty()); + + ApiKeyInfo apiKeyInfo = constructApiKeyInfo("Test API key description", true); + int expectedSize = 10; + for (int i = 0; i < expectedSize; i++) { + doPost("/api/apiKey", apiKeyInfo, String.class); + } + + PageData pageData2 = doGetTypedWithPageLink("/api/apiKeys/" + tenantAdminUserId + "?", new TypeReference<>() {}, new PageLink(10, 0)); + Assert.assertEquals(expectedSize, pageData2.getData().size()); + + pageData2.getData().forEach(apiKey -> { + try { + doDelete("/api/apiKey/" + apiKey.getId()).andExpect(status().isOk()); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + @Test + public void testUpdateApiKeyDescription() throws Exception { + ApiKeyInfo apiKeyInfo = constructApiKeyInfo("Test API key description", true); + doPost("/api/apiKey", apiKeyInfo, String.class); + + PageData pageData = doGetTypedWithPageLink("/api/apiKeys/" + tenantAdminUserId + "?", new TypeReference<>() {}, new PageLink(10, 0)); + Assert.assertEquals(1, pageData.getData().size()); + + ApiKeyInfo savedApiKey = pageData.getData().get(0); + + String newDescription = "Updated API Key Description"; + + ApiKeyInfo updatedApiKeyInfo = doPut("/api/apiKey/" + savedApiKey.getId().getId() + "/description", newDescription, ApiKeyInfo.class); + Assert.assertNotNull(updatedApiKeyInfo); + Assert.assertEquals(newDescription, updatedApiKeyInfo.getDescription()); + + doDelete("/api/apiKey/" + savedApiKey.getId()).andExpect(status().isOk()); + } + + @Test + public void testEnableApiKey() throws Exception { + ApiKeyInfo apiKeyInfo = constructApiKeyInfo("Test API key description", true); + doPost("/api/apiKey", apiKeyInfo, String.class); + + PageData pageData = doGetTypedWithPageLink("/api/apiKeys/" + tenantAdminUserId + "?", new TypeReference<>() {}, new PageLink(10, 0)); + Assert.assertEquals(1, pageData.getData().size()); + + ApiKeyInfo savedApiKey = pageData.getData().get(0); + + ApiKeyInfo disabledApiKeyInfo = doPut("/api/apiKey/" + savedApiKey.getId().getId() + "/enabled/false", Boolean.FALSE, ApiKeyInfo.class); + Assert.assertNotNull(disabledApiKeyInfo); + Assert.assertFalse(disabledApiKeyInfo.isEnabled()); + + ApiKeyInfo enabledApiKeyInfo = doPut("/api/apiKey/" + savedApiKey.getId().getId() + "/enabled/true", Boolean.TRUE, ApiKeyInfo.class); + Assert.assertNotNull(enabledApiKeyInfo); + Assert.assertTrue(enabledApiKeyInfo.isEnabled()); + + doDelete("/api/apiKey/" + savedApiKey.getId()).andExpect(status().isOk()); + } + + @Test + public void testDeleteApiKey() throws Exception { + doDelete("/api/apiKey/" + UUID.randomUUID()).andExpect(status().isNotFound()); + + ApiKeyInfo apiKeyInfo = constructApiKeyInfo("Test API key description", false); + doPost("/api/apiKey", apiKeyInfo, String.class); + + PageData pageData = doGetTypedWithPageLink("/api/apiKeys/" + tenantAdminUserId + "?", new TypeReference<>() {}, new PageLink(10, 0)); + Assert.assertEquals(1, pageData.getData().size()); + ApiKeyInfo savedApiKey = pageData.getData().get(0); + + doDelete("/api/apiKey/" + savedApiKey.getId().getId()).andExpect(status().isOk()); + } + + private ApiKeyInfo constructApiKeyInfo(String description, boolean enabled) { + ApiKeyInfo apiKeyInfo = new ApiKeyInfo(); + apiKeyInfo.setDescription(description); + apiKeyInfo.setEnabled(enabled); + return apiKeyInfo; + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/security/auth/JwtTokenFactoryTest.java b/application/src/test/java/org/thingsboard/server/service/security/auth/JwtTokenFactoryTest.java index 3eeab8b7d9..3ba082e79b 100644 --- a/application/src/test/java/org/thingsboard/server/service/security/auth/JwtTokenFactoryTest.java +++ b/application/src/test/java/org/thingsboard/server/service/security/auth/JwtTokenFactoryTest.java @@ -60,7 +60,7 @@ public class JwtTokenFactoryTest { public void beforeEach() { jwtSettings = new JwtSettings(); jwtSettings.setTokenIssuer("tb"); - jwtSettings.setTokenSigningKey(Base64.getEncoder().encodeToString(RandomStringUtils.randomAlphanumeric(64).getBytes(StandardCharsets.UTF_8))); + jwtSettings.setTokenSigningKey(Base64.getEncoder().encodeToString(RandomStringUtils.secure().nextAlphanumeric(64).getBytes(StandardCharsets.UTF_8))); jwtSettings.setTokenExpirationTime((int) TimeUnit.HOURS.toSeconds(2)); jwtSettings.setRefreshTokenExpTime((int) TimeUnit.DAYS.toSeconds(7)); @@ -89,7 +89,7 @@ public class JwtTokenFactoryTest { AccessJwtToken accessToken = tokenFactory.createAccessJwtToken(securityUser); checkExpirationTime(accessToken, jwtSettings.getTokenExpirationTime()); - SecurityUser parsedSecurityUser = tokenFactory.parseAccessJwtToken(accessToken.getToken()); + SecurityUser parsedSecurityUser = tokenFactory.parseAccessJwtToken(accessToken.token()); assertThat(parsedSecurityUser.getId()).isEqualTo(securityUser.getId()); assertThat(parsedSecurityUser.getEmail()).isEqualTo(securityUser.getEmail()); assertThat(parsedSecurityUser.getUserPrincipal()).matches(userPrincipal -> { @@ -112,7 +112,7 @@ public class JwtTokenFactoryTest { JwtToken refreshToken = tokenFactory.createRefreshToken(securityUser); checkExpirationTime(refreshToken, jwtSettings.getRefreshTokenExpTime()); - SecurityUser parsedSecurityUser = tokenFactory.parseRefreshToken(refreshToken.getToken()); + SecurityUser parsedSecurityUser = tokenFactory.parseRefreshToken(refreshToken.token()); assertThat(parsedSecurityUser.getId()).isEqualTo(securityUser.getId()); assertThat(parsedSecurityUser.getUserPrincipal()).matches(userPrincipal -> { return userPrincipal.getType().equals(securityUser.getUserPrincipal().getType()) @@ -128,7 +128,7 @@ public class JwtTokenFactoryTest { JwtToken preVerificationToken = tokenFactory.createPreVerificationToken(securityUser, tokenLifetime); checkExpirationTime(preVerificationToken, tokenLifetime); - SecurityUser parsedSecurityUser = tokenFactory.parseAccessJwtToken(preVerificationToken.getToken()); + SecurityUser parsedSecurityUser = tokenFactory.parseAccessJwtToken(preVerificationToken.token()); assertThat(parsedSecurityUser.getId()).isEqualTo(securityUser.getId()); assertThat(parsedSecurityUser.getAuthority()).isEqualTo(Authority.PRE_VERIFICATION_TOKEN); assertThat(parsedSecurityUser.getTenantId()).isEqualTo(securityUser.getTenantId()); @@ -144,7 +144,7 @@ public class JwtTokenFactoryTest { SecurityUser securityUser = createSecurityUser(); String sessionId = securityUser.getSessionId(); - String accessToken = tokenFactory.createAccessJwtToken(securityUser).getToken(); + String accessToken = tokenFactory.createAccessJwtToken(securityUser).token(); securityUser = tokenFactory.parseAccessJwtToken(accessToken); assertThat(securityUser.getSessionId()).isNotNull().isEqualTo(sessionId); @@ -158,7 +158,7 @@ public class JwtTokenFactoryTest { securityUser.setId(new UserId(UUID.randomUUID())); securityUser.setEmail("tenant@thingsboard.org"); securityUser.setAuthority(Authority.TENANT_ADMIN); - securityUser.setTenantId(new TenantId(UUID.randomUUID())); + securityUser.setTenantId(TenantId.fromUUID(UUID.randomUUID())); securityUser.setEnabled(true); securityUser.setFirstName("A"); securityUser.setLastName("B"); @@ -179,7 +179,7 @@ public class JwtTokenFactoryTest { } private void checkExpirationTime(JwtToken jwtToken, int tokenLifetime) { - Claims claims = tokenFactory.parseTokenClaims(jwtToken.getToken()).getPayload(); + Claims claims = tokenFactory.parseTokenClaims(jwtToken.token()).getPayload(); assertThat(claims.getExpiration()).matches(actualExpirationTime -> { Calendar expirationTime = Calendar.getInstance(); expirationTime.setTime(new Date()); diff --git a/application/src/test/java/org/thingsboard/server/service/security/auth/TokenOutdatingTest.java b/application/src/test/java/org/thingsboard/server/service/security/auth/TokenOutdatingTest.java index aebd98a5bf..49e4ed706c 100644 --- a/application/src/test/java/org/thingsboard/server/service/security/auth/TokenOutdatingTest.java +++ b/application/src/test/java/org/thingsboard/server/service/security/auth/TokenOutdatingTest.java @@ -114,12 +114,12 @@ public class TokenOutdatingTest { // Token outdatage time is rounded to 1 sec. Need to wait before outdating so that outdatage time is strictly after token issue time SECONDS.sleep(1); eventPublisher.publishEvent(new UserCredentialsInvalidationEvent(securityUser.getId())); - assertTrue(tokenOutdatingService.isOutdated(jwtToken.getToken(), securityUser.getId())); + assertTrue(tokenOutdatingService.isOutdated(jwtToken.token(), securityUser.getId())); SECONDS.sleep(1); JwtToken newJwtToken = tokenFactory.createAccessJwtToken(securityUser); - assertFalse(tokenOutdatingService.isOutdated(newJwtToken.getToken(), securityUser.getId())); + assertFalse(tokenOutdatingService.isOutdated(newJwtToken.token(), securityUser.getId())); } @Test @@ -229,7 +229,7 @@ public class TokenOutdatingTest { private RawAccessJwtToken getRawJwtToken(JwtToken token) { - return new RawAccessJwtToken(token.getToken()); + return new RawAccessJwtToken(token.token()); } private SecurityUser createMockSecurityUser(UserId userId) { @@ -241,4 +241,5 @@ public class TokenOutdatingTest { securityUser.setSessionId(UUID.randomUUID().toString()); return securityUser; } + } diff --git a/application/src/test/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProviderTest.java b/application/src/test/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProviderTest.java new file mode 100644 index 0000000000..a0f07549e7 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProviderTest.java @@ -0,0 +1,189 @@ +/** + * Copyright © 2016-2025 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.service.security.auth.pat; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.DisabledException; +import org.springframework.security.authentication.InsufficientAuthenticationException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.id.ApiKeyId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.pat.ApiKey; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.common.data.security.UserCredentials; +import org.thingsboard.server.dao.pat.ApiKeyService; +import org.thingsboard.server.dao.user.UserService; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.model.token.RawApiKeyToken; + +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.when; + +@RunWith(MockitoJUnitRunner.class) +public class ApiKeyAuthenticationProviderTest { + + private static final String TEST_API_KEY = "test_api_key"; + private static final String USER_EMAIL = "test@example.com"; + + @Mock + private ApiKeyService apiKeyService; + + @Mock + private UserService userService; + + private ApiKeyAuthenticationProvider provider; + private TenantId tenantId; + private UserId userId; + private User user; + private UserCredentials userCredentials; + private ApiKey apiKey; + + @Before + public void setUp() { + provider = new ApiKeyAuthenticationProvider(apiKeyService, userService); + tenantId = TenantId.fromUUID(UUID.randomUUID()); + userId = new UserId(UUID.randomUUID()); + + user = new User(); + user.setId(userId); + user.setTenantId(tenantId); + user.setEmail(USER_EMAIL); + user.setAuthority(Authority.TENANT_ADMIN); + + userCredentials = new UserCredentials(); + userCredentials.setEnabled(true); + + apiKey = new ApiKey(); + apiKey.setId(new ApiKeyId(UUID.randomUUID())); + apiKey.setTenantId(tenantId); + apiKey.setUserId(userId); + apiKey.setHash(TEST_API_KEY); + apiKey.setEnabled(true); + apiKey.setExpirationTime(0); + } + + @Test + public void testSuccessfulAuthentication() { + when(apiKeyService.findApiKeyByHash(TEST_API_KEY)).thenReturn(apiKey); + when(userService.findUserById(tenantId, userId)).thenReturn(user); + when(userService.findUserCredentialsByUserId(tenantId, userId)).thenReturn(userCredentials); + + ApiKeyAuthenticationToken token = new ApiKeyAuthenticationToken(new RawApiKeyToken(TEST_API_KEY)); + + Authentication authentication = provider.authenticate(token); + + assertNotNull(authentication); + assertTrue(authentication.isAuthenticated()); + assertTrue(authentication instanceof ApiKeyAuthenticationToken); + SecurityUser securityUser = (SecurityUser) authentication.getPrincipal(); + assertEquals(userId, securityUser.getId()); + assertEquals(tenantId, securityUser.getTenantId()); + assertEquals(USER_EMAIL, securityUser.getEmail()); + assertEquals(Authority.TENANT_ADMIN, securityUser.getAuthority()); + } + + @Test(expected = BadCredentialsException.class) + public void testEmptyApiKey() { + ApiKeyAuthenticationToken token = new ApiKeyAuthenticationToken(new RawApiKeyToken("")); + + provider.authenticate(token); + } + + @Test(expected = UsernameNotFoundException.class) + public void testNonExistentApiKey() { + when(apiKeyService.findApiKeyByHash(TEST_API_KEY)).thenReturn(null); + ApiKeyAuthenticationToken token = new ApiKeyAuthenticationToken(new RawApiKeyToken(TEST_API_KEY)); + + provider.authenticate(token); + } + + @Test(expected = DisabledException.class) + public void testDisabledApiKey() { + apiKey.setEnabled(false); + when(apiKeyService.findApiKeyByHash(TEST_API_KEY)).thenReturn(apiKey); + ApiKeyAuthenticationToken token = new ApiKeyAuthenticationToken(new RawApiKeyToken(TEST_API_KEY)); + + provider.authenticate(token); + } + + @Test(expected = BadCredentialsException.class) + public void testExpiredApiKey() { + apiKey.setExpirationTime(System.currentTimeMillis() - 10000); // Expired 10 seconds ago + when(apiKeyService.findApiKeyByHash(TEST_API_KEY)).thenReturn(apiKey); + ApiKeyAuthenticationToken token = new ApiKeyAuthenticationToken(new RawApiKeyToken(TEST_API_KEY)); + + provider.authenticate(token); + } + + @Test(expected = UsernameNotFoundException.class) + public void testNonExistentUser() { + when(apiKeyService.findApiKeyByHash(TEST_API_KEY)).thenReturn(apiKey); + when(userService.findUserById(tenantId, userId)).thenReturn(null); + ApiKeyAuthenticationToken token = new ApiKeyAuthenticationToken(new RawApiKeyToken(TEST_API_KEY)); + + provider.authenticate(token); + } + + @Test(expected = UsernameNotFoundException.class) + public void testNonExistentUserCredentials() { + when(apiKeyService.findApiKeyByHash(TEST_API_KEY)).thenReturn(apiKey); + when(userService.findUserById(tenantId, userId)).thenReturn(user); + when(userService.findUserCredentialsByUserId(tenantId, userId)).thenReturn(null); + ApiKeyAuthenticationToken token = new ApiKeyAuthenticationToken(new RawApiKeyToken(TEST_API_KEY)); + + provider.authenticate(token); + } + + @Test(expected = DisabledException.class) + public void testDisabledUser() { + userCredentials.setEnabled(false); + when(apiKeyService.findApiKeyByHash(TEST_API_KEY)).thenReturn(apiKey); + when(userService.findUserById(tenantId, userId)).thenReturn(user); + when(userService.findUserCredentialsByUserId(tenantId, userId)).thenReturn(userCredentials); + ApiKeyAuthenticationToken token = new ApiKeyAuthenticationToken(new RawApiKeyToken(TEST_API_KEY)); + + provider.authenticate(token); + } + + @Test(expected = InsufficientAuthenticationException.class) + public void testUserWithoutAuthority() { + user.setAuthority(null); + when(apiKeyService.findApiKeyByHash(TEST_API_KEY)).thenReturn(apiKey); + when(userService.findUserById(tenantId, userId)).thenReturn(user); + when(userService.findUserCredentialsByUserId(tenantId, userId)).thenReturn(userCredentials); + ApiKeyAuthenticationToken token = new ApiKeyAuthenticationToken(new RawApiKeyToken(TEST_API_KEY)); + + provider.authenticate(token); + } + + @Test + public void testSupports() { + assertTrue(provider.supports(ApiKeyAuthenticationToken.class)); + } + +} diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/pat/ApiKeyService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/pat/ApiKeyService.java new file mode 100644 index 0000000000..091d23bf92 --- /dev/null +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/pat/ApiKeyService.java @@ -0,0 +1,43 @@ +/** + * Copyright © 2016-2025 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.dao.pat; + +import org.thingsboard.server.common.data.id.ApiKeyId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.pat.ApiKey; +import org.thingsboard.server.common.data.pat.ApiKeyInfo; +import org.thingsboard.server.dao.entity.EntityDaoService; + +public interface ApiKeyService extends EntityDaoService { + + ApiKey saveApiKey(TenantId tenantId, ApiKeyInfo apiKey); + + void deleteApiKey(TenantId tenantId, ApiKey apiKey, boolean force); + + void deleteByTenantId(TenantId tenantId); + + void deleteByUserId(TenantId tenantId, UserId userId); + + ApiKey findApiKeyByHash(String hash); + + ApiKey findApiKeyById(TenantId tenantId, ApiKeyId apiKeyId); + + PageData findApiKeysByUserId(TenantId tenantId, UserId userId, PageLink pageLink); + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java b/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java index b55453f393..c97a3a9a21 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java @@ -40,6 +40,7 @@ public final class CacheConstants { public static final String SENT_NOTIFICATIONS_CACHE = "sentNotifications"; public static final String TRENDZ_SETTINGS_CACHE = "trendzSettings"; public static final String AI_MODEL_CACHE = "aiModel"; + public static final String API_KEYS_CACHE = "apiKeys"; public static final String ASSET_PROFILE_CACHE = "assetProfiles"; public static final String ATTRIBUTES_CACHE = "attributes"; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java b/common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java index 110052b57f..b4cba03d81 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java @@ -17,6 +17,7 @@ package org.thingsboard.server.common.data; import lombok.Getter; import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; import java.util.Arrays; import java.util.EnumSet; @@ -69,6 +70,12 @@ public enum EntityType { public String getNormalName() { return "AI model"; } + }, + API_KEY(44, "api_key") { + @Override + public String getNormalName() { + return "API key"; + } }; @Getter @@ -76,7 +83,7 @@ public enum EntityType { @Getter private final String tableName; @Getter - private final String normalName = StringUtils.capitalize(StringUtils.removeStart(name(), "TB_") + private final String normalName = StringUtils.capitalize(Strings.CS.removeStart(name(), "TB_") .toLowerCase().replaceAll("_", " ")); public static final List NORMAL_NAMES = EnumSet.allOf(EntityType.class).stream() diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/StringUtils.java b/common/data/src/main/java/org/thingsboard/server/common/data/StringUtils.java index cbc881d72f..c84e2bec7c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/StringUtils.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/StringUtils.java @@ -17,12 +17,14 @@ package org.thingsboard.server.common.data; import com.google.common.base.Splitter; import org.apache.commons.lang3.RandomStringUtils; +import org.apache.commons.lang3.Strings; import java.security.SecureRandom; import java.util.ArrayList; import java.util.Arrays; import java.util.Base64; import java.util.List; +import java.util.Objects; import java.util.function.Function; import static org.apache.commons.lang3.StringUtils.repeat; @@ -131,7 +133,7 @@ public class StringUtils { } public static boolean endsWith(String str, String suffix) { - return org.apache.commons.lang3.StringUtils.endsWith(str, suffix); + return Strings.CS.endsWith(str, suffix); } public static boolean hasLength(String str) { @@ -147,7 +149,7 @@ public class StringUtils { } public static String defaultString(String s, String defaultValue) { - return org.apache.commons.lang3.StringUtils.defaultString(s, defaultValue); + return Objects.toString(s, defaultValue); } public static boolean isNumeric(String str) { @@ -155,7 +157,7 @@ public class StringUtils { } public static boolean equals(String str1, String str2) { - return org.apache.commons.lang3.StringUtils.equals(str1, str2); + return Strings.CS.equals(str1, str2); } public static boolean equalsAny(String string, String... otherStrings) { @@ -199,7 +201,7 @@ public class StringUtils { } public static boolean contains(final CharSequence seq, final CharSequence searchSeq) { - return org.apache.commons.lang3.StringUtils.contains(seq, searchSeq); + return Strings.CS.contains(seq, searchSeq); } /** @@ -210,23 +212,23 @@ public class StringUtils { } public static String randomNumeric(int length) { - return RandomStringUtils.randomNumeric(length); + return RandomStringUtils.secure().nextNumeric(length); } public static String random(int length) { - return RandomStringUtils.random(length); + return RandomStringUtils.secure().next(length); } public static String random(int length, String chars) { - return RandomStringUtils.random(length, chars); + return RandomStringUtils.secure().next(length, chars); } public static String randomAlphanumeric(int count) { - return RandomStringUtils.randomAlphanumeric(count); + return RandomStringUtils.secure().nextAlphanumeric(count); } public static String randomAlphabetic(int count) { - return RandomStringUtils.randomAlphabetic(count); + return RandomStringUtils.secure().nextAlphabetic(count); } public static String generateSafeToken(int length) { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/ApiKeyId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/ApiKeyId.java new file mode 100644 index 0000000000..7f0cf20ade --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/ApiKeyId.java @@ -0,0 +1,46 @@ +/** + * Copyright © 2016-2025 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.id; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import org.thingsboard.server.common.data.EntityType; + +import java.io.Serial; +import java.util.UUID; + +public class ApiKeyId extends UUIDBased implements EntityId { + + @Serial + private static final long serialVersionUID = -273913539653684641L; + + @JsonCreator + public ApiKeyId(@JsonProperty("id") UUID id) { + super(id); + } + + public static ApiKeyId fromString(String secretId) { + return new ApiKeyId(UUID.fromString(secretId)); + } + + @Override + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "API_KEY", allowableValues = "API_KEY") + public EntityType getEntityType() { + return EntityType.API_KEY; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java index fc1c1cd1a9..2a7a4f03e4 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java @@ -84,6 +84,7 @@ public class EntityIdFactory { case JOB -> new JobId(uuid); case ADMIN_SETTINGS -> new AdminSettingsId(uuid); case AI_MODEL -> new AiModelId(uuid); + case API_KEY -> new ApiKeyId(uuid); }; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/pat/ApiKey.java b/common/data/src/main/java/org/thingsboard/server/common/data/pat/ApiKey.java new file mode 100644 index 0000000000..7188ed3ef5 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/pat/ApiKey.java @@ -0,0 +1,63 @@ +/** + * Copyright © 2016-2025 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.pat; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.thingsboard.server.common.data.id.ApiKeyId; +import org.thingsboard.server.common.data.validation.NoXss; + +import java.io.Serial; + +@Schema +@Data +@EqualsAndHashCode(callSuper = true) +public class ApiKey extends ApiKeyInfo { + + @Serial + private static final long serialVersionUID = -2313196723950490263L; + + @NoXss + @Schema(description = "Api key hash value", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonIgnore + private String hash; + + public ApiKey() { + super(); + } + + public ApiKey(ApiKeyId id) { + super(id); + } + + public ApiKey(ApiKey apiKey) { + super(apiKey); + this.hash = apiKey.getHash(); + } + + public ApiKey(ApiKeyInfo apiKeyInfo) { + super(apiKeyInfo); + this.hash = null; + } + + public ApiKey(ApiKeyInfo apiKeyInfo, String hash) { + super(apiKeyInfo); + this.hash = hash; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/pat/ApiKeyInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/pat/ApiKeyInfo.java new file mode 100644 index 0000000000..25c0ddce1c --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/pat/ApiKeyInfo.java @@ -0,0 +1,82 @@ +/** + * Copyright © 2016-2025 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.pat; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.thingsboard.server.common.data.BaseData; +import org.thingsboard.server.common.data.HasTenantId; +import org.thingsboard.server.common.data.id.ApiKeyId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.validation.Length; +import org.thingsboard.server.common.data.validation.NoXss; + +import java.io.Serial; + +@Schema +@Data +@EqualsAndHashCode(callSuper = true) +public class ApiKeyInfo extends BaseData implements HasTenantId { + + @Serial + private static final long serialVersionUID = -2313196723950490263L; + + @Schema(description = "JSON object with Tenant Id. Tenant Id of the api key cannot be changed.", accessMode = Schema.AccessMode.READ_ONLY) + private TenantId tenantId; + + @Schema(description = "JSON object with User Id. User Id of the api key cannot be changed.", accessMode = Schema.AccessMode.READ_ONLY) + private UserId userId; + + @Schema(description = "Expiration time of the api key.") + private long expirationTime; + + @NoXss + @Length(fieldName = "description") + @Schema(description = "Api Key description.", example = "Api Key description") + private String description; + + @Schema(description = "Enabled/disabled api key.", example = "true") + private boolean enabled; + + @Schema(description = "JSON object with the Api Key Id. " + + "Specify this field to update the Api Key. " + + "Referencing non-existing Api Key Id will cause error. " + + "Omit this field to create new Api Key.") + @Override + public ApiKeyId getId() { + return super.getId(); + } + + public ApiKeyInfo() { + super(); + } + + public ApiKeyInfo(ApiKeyId id) { + super(id); + } + + public ApiKeyInfo(ApiKeyInfo apiKeyInfo) { + super(apiKeyInfo); + this.tenantId = apiKeyInfo.getTenantId(); + this.userId = apiKeyInfo.getUserId(); + this.expirationTime = apiKeyInfo.getExpirationTime(); + this.enabled = apiKeyInfo.isEnabled(); + this.description = apiKeyInfo.getDescription(); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/JwtToken.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/JwtToken.java index ac91a9b8ad..1bb3697450 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/JwtToken.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/JwtToken.java @@ -18,5 +18,7 @@ package org.thingsboard.server.common.data.security.model; import java.io.Serializable; public interface JwtToken extends Serializable { - String getToken(); + + String token(); + } diff --git a/common/proto/src/main/proto/queue.proto b/common/proto/src/main/proto/queue.proto index a05fdd5d36..75224333c6 100644 --- a/common/proto/src/main/proto/queue.proto +++ b/common/proto/src/main/proto/queue.proto @@ -66,6 +66,7 @@ enum EntityTypeProto { JOB = 41; ADMIN_SETTINGS = 42; AI_MODEL = 43; + API_KEY = 44; } enum ApiUsageRecordKeyProto { diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java index 3960c67525..6cc51e93c1 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java @@ -760,6 +760,17 @@ public class ModelConstants { public static final String AI_MODEL_NAME_COLUMN_NAME = NAME_PROPERTY; public static final String AI_MODEL_CONFIGURATION_COLUMN_NAME = "configuration"; + /** + * Api Key constants. + */ + public static final String API_KEY_TABLE_NAME = "api_key"; + public static final String API_KEY_TENANT_ID_COLUMN_NAME = TENANT_ID_COLUMN; + public static final String API_KEY_USER_ID_COLUMN_NAME = USER_ID_PROPERTY; + public static final String API_KEY_HASH_COLUMN_NAME = "hash"; + public static final String API_KEY_EXPIRATION_TIME_COLUMN_NAME = "expiration_time"; + public static final String API_KEY_ENABLED_COLUMN_NAME = "enabled"; + public static final String API_KEY_DESCRIPTION_COLUMN_NAME = "description"; + protected static final String[] NONE_AGGREGATION_COLUMNS = new String[]{LONG_VALUE_COLUMN, DOUBLE_VALUE_COLUMN, BOOLEAN_VALUE_COLUMN, STRING_VALUE_COLUMN, JSON_VALUE_COLUMN, KEY_COLUMN, TS_COLUMN}; protected static final String[] COUNT_AGGREGATION_COLUMNS = new String[]{count(LONG_VALUE_COLUMN), count(DOUBLE_VALUE_COLUMN), count(BOOLEAN_VALUE_COLUMN), count(STRING_VALUE_COLUMN), count(JSON_VALUE_COLUMN), max(TS_COLUMN)}; diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractApiKeyInfoEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractApiKeyInfoEntity.java new file mode 100644 index 0000000000..ff41f566bc --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractApiKeyInfoEntity.java @@ -0,0 +1,81 @@ +/** + * Copyright © 2016-2025 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.dao.model.sql; + +import jakarta.persistence.Column; +import jakarta.persistence.MappedSuperclass; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.thingsboard.server.common.data.id.ApiKeyId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.pat.ApiKeyInfo; +import org.thingsboard.server.dao.model.BaseEntity; +import org.thingsboard.server.dao.model.BaseSqlEntity; + +import java.util.UUID; + +import static org.thingsboard.server.dao.model.ModelConstants.API_KEY_DESCRIPTION_COLUMN_NAME; +import static org.thingsboard.server.dao.model.ModelConstants.API_KEY_ENABLED_COLUMN_NAME; +import static org.thingsboard.server.dao.model.ModelConstants.API_KEY_EXPIRATION_TIME_COLUMN_NAME; +import static org.thingsboard.server.dao.model.ModelConstants.API_KEY_TENANT_ID_COLUMN_NAME; +import static org.thingsboard.server.dao.model.ModelConstants.API_KEY_USER_ID_COLUMN_NAME; + +@Data +@EqualsAndHashCode(callSuper = true) +@MappedSuperclass +public abstract class AbstractApiKeyInfoEntity extends BaseSqlEntity implements BaseEntity { + + @Column(name = API_KEY_TENANT_ID_COLUMN_NAME) + private UUID tenantId; + + @Column(name = API_KEY_USER_ID_COLUMN_NAME) + private UUID userId; + + @Column(name = API_KEY_EXPIRATION_TIME_COLUMN_NAME) + private long expirationTime; + + @Column(name = API_KEY_ENABLED_COLUMN_NAME) + private boolean enabled; + + @Column(name = API_KEY_DESCRIPTION_COLUMN_NAME) + private String description; + + public AbstractApiKeyInfoEntity() { + super(); + } + + public AbstractApiKeyInfoEntity(ApiKeyInfo apiKeyInfo) { + super(apiKeyInfo); + this.tenantId = apiKeyInfo.getTenantId().getId(); + this.userId = apiKeyInfo.getUserId().getId(); + this.expirationTime = apiKeyInfo.getExpirationTime(); + this.description = apiKeyInfo.getDescription(); + this.enabled = apiKeyInfo.isEnabled(); + } + + protected ApiKeyInfo toApiKeyInfo() { + ApiKeyInfo apiKeyInfo = new ApiKeyInfo(new ApiKeyId(getUuid())); + apiKeyInfo.setCreatedTime(createdTime); + apiKeyInfo.setTenantId(TenantId.fromUUID(tenantId)); + apiKeyInfo.setUserId(new UserId(userId)); + apiKeyInfo.setEnabled(enabled); + apiKeyInfo.setExpirationTime(expirationTime); + apiKeyInfo.setDescription(description); + return apiKeyInfo; + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/ApiKeyEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/ApiKeyEntity.java new file mode 100644 index 0000000000..30126ac699 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/ApiKeyEntity.java @@ -0,0 +1,51 @@ +/** + * Copyright © 2016-2025 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.dao.model.sql; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.thingsboard.server.common.data.pat.ApiKey; + +import static org.thingsboard.server.dao.model.ModelConstants.API_KEY_HASH_COLUMN_NAME; +import static org.thingsboard.server.dao.model.ModelConstants.API_KEY_TABLE_NAME; + +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = API_KEY_TABLE_NAME) +public class ApiKeyEntity extends AbstractApiKeyInfoEntity { + + @Column(name = API_KEY_HASH_COLUMN_NAME) + private String hash; + + public ApiKeyEntity() { + super(); + } + + public ApiKeyEntity(ApiKey apiKey) { + super(apiKey); + this.hash = apiKey.getHash(); + } + + @Override + public ApiKey toData() { + return new ApiKey(super.toApiKeyInfo(), hash); + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/ApiKeyInfoEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/ApiKeyInfoEntity.java new file mode 100644 index 0000000000..1ca6336027 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/ApiKeyInfoEntity.java @@ -0,0 +1,46 @@ +/** + * Copyright © 2016-2025 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.dao.model.sql; + +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.thingsboard.server.common.data.pat.ApiKey; +import org.thingsboard.server.common.data.pat.ApiKeyInfo; + +import static org.thingsboard.server.dao.model.ModelConstants.API_KEY_TABLE_NAME; + +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = API_KEY_TABLE_NAME) +public class ApiKeyInfoEntity extends AbstractApiKeyInfoEntity { + + public ApiKeyInfoEntity() { + super(); + } + + public ApiKeyInfoEntity(ApiKey apiKey) { + super(apiKey); + } + + @Override + public ApiKeyInfo toData() { + return super.toApiKeyInfo(); + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyCacheKey.java b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyCacheKey.java new file mode 100644 index 0000000000..d01cc84efb --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyCacheKey.java @@ -0,0 +1,40 @@ +/** + * Copyright © 2016-2025 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.dao.pat; + +import org.checkerframework.checker.nullness.qual.NonNull; + +import java.io.Serializable; + +import static java.util.Objects.requireNonNull; + +record ApiKeyCacheKey(String hash) implements Serializable { + + ApiKeyCacheKey { + requireNonNull(hash); + } + + static ApiKeyCacheKey of(String hash) { + return new ApiKeyCacheKey(hash); + } + + @NonNull + @Override + public String toString() { + return /* cache name */ "_" + hash; + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyCaffeineCache.java b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyCaffeineCache.java new file mode 100644 index 0000000000..48eab7a32c --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyCaffeineCache.java @@ -0,0 +1,33 @@ +/** + * Copyright © 2016-2025 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.dao.pat; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.cache.CacheManager; +import org.springframework.stereotype.Service; +import org.thingsboard.server.cache.CaffeineTbTransactionalCache; +import org.thingsboard.server.common.data.CacheConstants; +import org.thingsboard.server.common.data.pat.ApiKey; + +@ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "caffeine", matchIfMissing = true) +@Service("ApiKeyCache") +public class ApiKeyCaffeineCache extends CaffeineTbTransactionalCache { + + public ApiKeyCaffeineCache(CacheManager cacheManager) { + super(cacheManager, CacheConstants.API_KEYS_CACHE); + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyDao.java b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyDao.java new file mode 100644 index 0000000000..a48a746cbd --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyDao.java @@ -0,0 +1,33 @@ +/** + * Copyright © 2016-2025 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.dao.pat; + +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.pat.ApiKey; +import org.thingsboard.server.dao.Dao; + +import java.util.Set; + +public interface ApiKeyDao extends Dao { + + ApiKey findByHash(String hash); + + Set deleteByTenantId(TenantId tenantId); + + Set deleteByUserId(TenantId tenantId, UserId userId); + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyEvictEvent.java b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyEvictEvent.java new file mode 100644 index 0000000000..7f198f3adb --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyEvictEvent.java @@ -0,0 +1,19 @@ +/** + * Copyright © 2016-2025 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.dao.pat; + +public record ApiKeyEvictEvent(String hash) { +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyInfoDao.java b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyInfoDao.java new file mode 100644 index 0000000000..5b7b2022c5 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyInfoDao.java @@ -0,0 +1,29 @@ +/** + * Copyright © 2016-2025 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.dao.pat; + +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.pat.ApiKeyInfo; +import org.thingsboard.server.dao.Dao; + +public interface ApiKeyInfoDao extends Dao { + + PageData findByUserId(TenantId tenantId, UserId userId, PageLink pageLink); + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyRedisCache.java b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyRedisCache.java new file mode 100644 index 0000000000..eee0b8dc31 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyRedisCache.java @@ -0,0 +1,36 @@ +/** + * Copyright © 2016-2025 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.dao.pat; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.stereotype.Service; +import org.thingsboard.server.cache.CacheSpecsMap; +import org.thingsboard.server.cache.RedisTbTransactionalCache; +import org.thingsboard.server.cache.TBRedisCacheConfiguration; +import org.thingsboard.server.cache.TbJsonRedisSerializer; +import org.thingsboard.server.common.data.CacheConstants; +import org.thingsboard.server.common.data.pat.ApiKey; + +@ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "redis") +@Service("ApiKeyCache") +public class ApiKeyRedisCache extends RedisTbTransactionalCache { + + public ApiKeyRedisCache(TBRedisCacheConfiguration configuration, CacheSpecsMap cacheSpecsMap, RedisConnectionFactory connectionFactory) { + super(CacheConstants.API_KEYS_CACHE, cacheSpecsMap, connectionFactory, configuration, new TbJsonRedisSerializer<>(ApiKey.class)); + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyServiceImpl.java new file mode 100644 index 0000000000..1b232b3ebd --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyServiceImpl.java @@ -0,0 +1,159 @@ +/** + * Copyright © 2016-2025 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.dao.pat; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.event.TransactionalEventListener; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.id.ApiKeyId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.HasId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.pat.ApiKey; +import org.thingsboard.server.common.data.pat.ApiKeyInfo; +import org.thingsboard.server.dao.entity.AbstractCachedEntityService; +import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; +import org.thingsboard.server.dao.service.validator.ApiKeyDataValidator; + +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import static org.thingsboard.server.dao.service.Validator.validateId; +import static org.thingsboard.server.dao.user.UserServiceImpl.INCORRECT_TENANT_ID; +import static org.thingsboard.server.dao.user.UserServiceImpl.INCORRECT_USER_ID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ApiKeyServiceImpl extends AbstractCachedEntityService implements ApiKeyService { + + private static final String INCORRECT_API_KEY_ID = "Incorrect ApiKeyId "; + private static final int DEFAULT_API_KEY_BYTES = 32; + + private final ApiKeyDao apiKeyDao; + private final ApiKeyInfoDao apiKeyInfoDao; + private final ApiKeyDataValidator apiKeyValidator; + + @Override + @TransactionalEventListener + public void handleEvictEvent(ApiKeyEvictEvent event) { + cache.evict(ApiKeyCacheKey.of(event.hash())); + } + + @Override + public ApiKey saveApiKey(TenantId tenantId, ApiKeyInfo apiKeyInfo) { + log.trace("Executing saveApiKey [{}]", apiKeyInfo); + try { + var apiKey = new ApiKey(apiKeyInfo); + var old = apiKeyValidator.validate(apiKey, ApiKeyInfo::getTenantId); + if (old == null) { + String hash = generateApiKeySecret(); + apiKey.setHash(hash); + } else { + apiKey.setHash(old.getHash()); + } + var savedApiKey = apiKeyDao.save(tenantId, apiKey); + eventPublisher.publishEvent(SaveEntityEvent.builder().tenantId(tenantId).entityId(savedApiKey.getId()).entity(savedApiKey).created(apiKey.getId() == null).build()); + if (old != null && old.isEnabled() != apiKey.isEnabled()) { + publishEvictEvent(new ApiKeyEvictEvent(apiKey.getHash())); + } + return savedApiKey; + } catch (Exception e) { + checkConstraintViolation(e, "api_hash_unq_key", "Api Key with such hash already exists!"); + throw e; + } + } + + @Override + public ApiKey findApiKeyById(TenantId tenantId, ApiKeyId apiKeyId) { + log.trace("Executing findApiKeyById [{}] [{}]", tenantId, apiKeyId); + validateId(apiKeyId, id -> INCORRECT_API_KEY_ID + id); + return apiKeyDao.findById(tenantId, apiKeyId.getId()); + } + + @Override + public PageData findApiKeysByUserId(TenantId tenantId, UserId userId, PageLink pageLink) { + log.trace("Executing findApiKeysByUserId [{}][{}]", tenantId, userId); + validateId(userId, id -> INCORRECT_USER_ID + id); + return apiKeyInfoDao.findByUserId(tenantId, userId, pageLink); + } + + @Override + public Optional> findEntity(TenantId tenantId, EntityId entityId) { + return Optional.ofNullable(findApiKeyById(tenantId, new ApiKeyId(entityId.getId()))); + } + + @Override + public void deleteApiKey(TenantId tenantId, ApiKey apiKey, boolean force) { + deleteApiKey(tenantId, apiKey.getId()); + } + + @Override + public void deleteEntity(TenantId tenantId, EntityId id, boolean force) { + deleteApiKey(tenantId, id); + } + + private void deleteApiKey(TenantId tenantId, EntityId entityId) { + UUID apiKeyId = entityId.getId(); + validateId(apiKeyId, id -> INCORRECT_API_KEY_ID + id); + ApiKey apiKey = apiKeyDao.findById(tenantId, apiKeyId); + if (apiKey == null) { + return; + } + apiKeyDao.removeById(tenantId, apiKeyId); + publishEvictEvent(new ApiKeyEvictEvent(apiKey.getHash())); + } + + @Override + public void deleteByTenantId(TenantId tenantId) { + log.trace("Executing deleteApiKeysByTenantId, tenantId [{}]", tenantId); + validateId(tenantId, id -> INCORRECT_TENANT_ID + id); + Set hashes = apiKeyDao.deleteByTenantId(tenantId); + hashes.forEach(hash -> publishEvictEvent(new ApiKeyEvictEvent(hash))); + } + + @Override + public void deleteByUserId(TenantId tenantId, UserId userId) { + log.trace("Executing deleteApiKeysByUserId, tenantId [{}]", tenantId); + validateId(userId, id -> INCORRECT_USER_ID + id); + Set hashes = apiKeyDao.deleteByUserId(tenantId, userId); + hashes.forEach(hash -> publishEvictEvent(new ApiKeyEvictEvent(hash))); + } + + @Override + public ApiKey findApiKeyByHash(String hash) { + log.trace("Executing findApiKeyByHash [{}]", hash); + var cacheKey = ApiKeyCacheKey.of(hash); + return cache.getAndPutInTransaction(cacheKey, () -> apiKeyDao.findByHash(hash), true); + } + + private static String generateApiKeySecret() { + return StringUtils.generateSafeToken(DEFAULT_API_KEY_BYTES); + } + + @Override + public EntityType getEntityType() { + return EntityType.API_KEY; + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/ApiKeyDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/ApiKeyDataValidator.java new file mode 100644 index 0000000000..b65f651801 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/ApiKeyDataValidator.java @@ -0,0 +1,77 @@ +/** + * Copyright © 2016-2025 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.dao.service.validator; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.pat.ApiKey; +import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.dao.pat.ApiKeyDao; +import org.thingsboard.server.dao.service.DataValidator; +import org.thingsboard.server.dao.tenant.TenantDao; +import org.thingsboard.server.dao.user.UserDao; + +@Component +@RequiredArgsConstructor +public class ApiKeyDataValidator extends DataValidator { + + private final ApiKeyDao apiKeyDao; + private final TenantDao tenantDao; + private final UserDao userDao; + + @Override + protected void validateDataImpl(TenantId tenantId, ApiKey apiKey) { + if (apiKey.getId() != null) { + if (apiKey.getUuidId() == null) { + throw new DataValidationException("Api Key UUID should be specified!"); + } + if (apiKey.getId().isNullUid()) { + throw new DataValidationException("API key UUID must not be the reserved null value!"); + } + } + + if (apiKey.getTenantId() == null || apiKey.getTenantId().getId() == null) { + throw new DataValidationException("API key should be assigned to tenant!"); + } + if (tenantDao.findById(apiKey.getTenantId(), apiKey.getTenantId().getId()) == null) { + throw new DataValidationException("API key reference a non-existent tenant!"); + } + + if (apiKey.getUserId() == null || apiKey.getUserId().getId() == null) { + throw new DataValidationException("API key should be assigned to user!"); + } + if (userDao.findById(tenantId, apiKey.getUserId().getId()) == null) { + throw new DataValidationException("API key reference a non-existent user!"); + } + } + + @Override + protected ApiKey validateUpdate(TenantId tenantId, ApiKey apiKey) { + ApiKey old = apiKeyDao.findById(tenantId, apiKey.getUuidId()); + if (old == null) { + throw new DataValidationException("Cannot update non-existent API key!"); + } + if (!old.getUserId().equals(apiKey.getUserId())) { + throw new DataValidationException("Cannot update api key user id!"); + } + if (old.getExpirationTime() != apiKey.getExpirationTime()) { + throw new DataValidationException("Cannot update api key expiration time!"); + } + return old; + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyInfoRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyInfoRepository.java new file mode 100644 index 0000000000..f5a249ead2 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyInfoRepository.java @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2025 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.dao.sql.pat; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.thingsboard.server.dao.model.sql.ApiKeyInfoEntity; + +import java.util.UUID; + +public interface ApiKeyInfoRepository extends JpaRepository { + + @Query("SELECT k FROM ApiKeyInfoEntity k WHERE k.tenantId = :tenantId AND k.userId = :userId") + Page findByUserId(@Param("tenantId") UUID tenantId, + @Param("userId") UUID userId, + Pageable pageable); + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyRepository.java new file mode 100644 index 0000000000..d744b22ee9 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyRepository.java @@ -0,0 +1,53 @@ +/** + * Copyright © 2016-2025 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.dao.sql.pat; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; +import org.thingsboard.server.dao.model.sql.ApiKeyEntity; + +import java.util.Set; +import java.util.UUID; + +public interface ApiKeyRepository extends JpaRepository { + + ApiKeyEntity findByHash(String hash); + + @Transactional + @Modifying + @Query(value = """ + DELETE FROM api_key + WHERE tenant_id = :tenantId + RETURNING hash + """, nativeQuery = true + ) + Set deleteByTenantId(@Param("tenantId") UUID tenantId); + + @Transactional + @Modifying + @Query(value = """ + DELETE FROM api_key + WHERE tenant_id = :tenantId AND user_id = :userId + RETURNING hash + """, nativeQuery = true + ) + Set deleteByUserId(@Param("tenantId") UUID tenantId, + @Param("userId") UUID userId); + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/pat/JpaApiKeyDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/pat/JpaApiKeyDao.java new file mode 100644 index 0000000000..185a3ac951 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/pat/JpaApiKeyDao.java @@ -0,0 +1,73 @@ +/** + * Copyright © 2016-2025 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.dao.sql.pat; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.pat.ApiKey; +import org.thingsboard.server.dao.DaoUtil; +import org.thingsboard.server.dao.model.sql.ApiKeyEntity; +import org.thingsboard.server.dao.pat.ApiKeyDao; +import org.thingsboard.server.dao.sql.JpaAbstractDao; +import org.thingsboard.server.dao.util.SqlDao; + +import java.util.Set; +import java.util.UUID; + +@Slf4j +@SqlDao +@Component +public class JpaApiKeyDao extends JpaAbstractDao implements ApiKeyDao { + + @Autowired + private ApiKeyRepository apiKeyRepository; + + @Override + public ApiKey findByHash(String hash) { + return DaoUtil.getData(apiKeyRepository.findByHash(hash)); + } + + @Override + public Set deleteByTenantId(TenantId tenantId) { + return apiKeyRepository.deleteByTenantId(tenantId.getId()); + } + + @Override + public Set deleteByUserId(TenantId tenantId, UserId userId) { + return apiKeyRepository.deleteByUserId(tenantId.getId(), userId.getId()); + } + + @Override + protected Class getEntityClass() { + return ApiKeyEntity.class; + } + + @Override + protected JpaRepository getRepository() { + return apiKeyRepository; + } + + @Override + public EntityType getEntityType() { + return EntityType.API_KEY; + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/pat/JpaApiKeyInfoDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/pat/JpaApiKeyInfoDao.java new file mode 100644 index 0000000000..b133c42aed --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/pat/JpaApiKeyInfoDao.java @@ -0,0 +1,58 @@ +/** + * Copyright © 2016-2025 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.dao.sql.pat; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.pat.ApiKeyInfo; +import org.thingsboard.server.dao.DaoUtil; +import org.thingsboard.server.dao.model.sql.ApiKeyInfoEntity; +import org.thingsboard.server.dao.pat.ApiKeyInfoDao; +import org.thingsboard.server.dao.sql.JpaAbstractDao; +import org.thingsboard.server.dao.util.SqlDao; + +import java.util.UUID; + +@Slf4j +@SqlDao +@Component +public class JpaApiKeyInfoDao extends JpaAbstractDao implements ApiKeyInfoDao { + + @Autowired + private ApiKeyInfoRepository apiKeyInfoRepository; + + @Override + public PageData findByUserId(TenantId tenantId, UserId userId, PageLink pageLink) { + return DaoUtil.toPageData(apiKeyInfoRepository.findByUserId(tenantId.getId(), userId.getId(), DaoUtil.toPageable(pageLink))); + } + + @Override + protected Class getEntityClass() { + return ApiKeyInfoEntity.class; + } + + @Override + protected JpaRepository getRepository() { + return apiKeyInfoRepository; + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java index 0df7c36527..b7ba7126b6 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java @@ -179,7 +179,7 @@ public class TenantServiceImpl extends AbstractCachedEntityService userValidator; private final DataValidator userCredentialsValidator; @@ -332,6 +335,7 @@ public class UserServiceImpl extends AbstractCachedEntityService INCORRECT_USER_ID + id); userCredentialsDao.removeByUserId(tenantId, userId); userAuthSettingsDao.removeByUserId(userId); + apiKeyService.deleteByUserId(tenantId, userId); publishEvictEvent(new UserCacheEvictEvent(user.getTenantId(), user.getEmail(), null)); userSettingsDao.removeByUserId(tenantId, userId); userDao.removeById(tenantId, userId.getId()); diff --git a/dao/src/main/resources/sql/schema-entities-idx.sql b/dao/src/main/resources/sql/schema-entities-idx.sql index 12f314590a..36b231272e 100644 --- a/dao/src/main/resources/sql/schema-entities-idx.sql +++ b/dao/src/main/resources/sql/schema-entities-idx.sql @@ -20,7 +20,7 @@ CREATE INDEX IF NOT EXISTS idx_alarm_originator_created_time ON alarm(originator CREATE INDEX IF NOT EXISTS idx_alarm_tenant_created_time ON alarm(tenant_id, created_time DESC); --- Drop index by 'status' column and replace with new indexes that has only active alarms; +-- Drop index by 'status' column and replace with new indexes that have only active alarms; CREATE INDEX IF NOT EXISTS idx_alarm_originator_alarm_type_active ON alarm USING btree (originator_id, type) WHERE cleared = false; @@ -108,8 +108,6 @@ CREATE INDEX IF NOT EXISTS idx_notification_delivery_method_recipient_id_unread CREATE INDEX IF NOT EXISTS idx_resource_etag ON resource(tenant_id, etag); -CREATE INDEX IF NOT EXISTS idx_resource_etag ON resource(tenant_id, etag); - CREATE INDEX IF NOT EXISTS idx_resource_type_public_resource_key ON resource(resource_type, public_resource_key); CREATE INDEX IF NOT EXISTS mobile_app_bundle_tenant_id ON mobile_app_bundle(tenant_id); @@ -117,3 +115,5 @@ CREATE INDEX IF NOT EXISTS mobile_app_bundle_tenant_id ON mobile_app_bundle(tena CREATE INDEX IF NOT EXISTS idx_job_tenant_id ON job(tenant_id); CREATE INDEX IF NOT EXISTS idx_ai_model_tenant_id ON ai_model(tenant_id); + +CREATE INDEX IF NOT EXISTS idx_api_key_user_id ON api_key(user_id); diff --git a/dao/src/main/resources/sql/schema-entities.sql b/dao/src/main/resources/sql/schema-entities.sql index 6ccf2f6d95..31eb6e9d1b 100644 --- a/dao/src/main/resources/sql/schema-entities.sql +++ b/dao/src/main/resources/sql/schema-entities.sql @@ -709,6 +709,18 @@ CREATE TABLE IF NOT EXISTS api_usage_state ( CONSTRAINT api_usage_state_unq_key UNIQUE (tenant_id, entity_id) ); +CREATE TABLE IF NOT EXISTS api_key ( + id uuid NOT NULL CONSTRAINT api_key_pkey PRIMARY KEY, + created_time bigint NOT NULL, + tenant_id uuid, + user_id uuid, + hash varchar(255), + enabled boolean NOT NULL DEFAULT TRUE, + expiration_time bigint, + description varchar(1024), + CONSTRAINT api_hash_unq_key UNIQUE (hash) +); + CREATE TABLE IF NOT EXISTS resource ( id uuid NOT NULL CONSTRAINT resource_pkey PRIMARY KEY, created_time bigint NOT NULL, diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/ApiKeyServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/ApiKeyServiceTest.java new file mode 100644 index 0000000000..96bc7187ed --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/service/ApiKeyServiceTest.java @@ -0,0 +1,234 @@ +/** + * Copyright © 2016-2025 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.dao.service; + +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.pat.ApiKey; +import org.thingsboard.server.common.data.pat.ApiKeyInfo; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.dao.pat.ApiKeyService; +import org.thingsboard.server.dao.user.UserService; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DaoSqlTest +public class ApiKeyServiceTest extends AbstractServiceTest { + + private static final String TEST_API_KEY_DESCRIPTION = "Test API Key Description"; + + @Autowired + ApiKeyService apiKeyService; + @Autowired + UserService userService; + + private UserId userId; + + @Before + public void before() { + User tenantAdmin = new User(); + tenantAdmin.setAuthority(Authority.TENANT_ADMIN); + tenantAdmin.setTenantId(tenantId); + tenantAdmin.setEmail("tenant@thingsboard.org"); + User user = userService.saveUser(TenantId.SYS_TENANT_ID, tenantAdmin); + userId = user.getId(); + } + + @After + public void after() { + apiKeyService.deleteByTenantId(tenantId); + User user = userService.findUserById(tenantId, userId); + userService.deleteUser(tenantId, user); + } + + @Test + public void testSaveApiKey() { + ApiKeyInfo apiKeyInfo = createApiKeyInfo(TEST_API_KEY_DESCRIPTION); + ApiKey savedApiKey = apiKeyService.saveApiKey(tenantId, apiKeyInfo); + + Assert.assertNotNull(savedApiKey); + Assert.assertNotNull(savedApiKey.getId()); + Assert.assertEquals(tenantId, savedApiKey.getTenantId()); + Assert.assertEquals(TEST_API_KEY_DESCRIPTION, savedApiKey.getDescription()); + Assert.assertTrue(savedApiKey.isEnabled()); + Assert.assertNotNull(savedApiKey.getHash()); + } + + @Test + public void testSaveApiKeyWithEmptyDescription() { + ApiKeyInfo apiKeyInfo = createApiKeyInfo(null); + ApiKey savedApiKey = apiKeyService.saveApiKey(tenantId, apiKeyInfo); + + Assert.assertNotNull(savedApiKey); + Assert.assertNotNull(savedApiKey.getId()); + Assert.assertEquals(tenantId, savedApiKey.getTenantId()); + Assert.assertNull(savedApiKey.getDescription()); + Assert.assertTrue(savedApiKey.isEnabled()); + Assert.assertNotNull(savedApiKey.getHash()); + } + + @Test + public void testSaveApiKeyWithTooLongDescription() { + ApiKeyInfo apiKeyInfo = createApiKeyInfo(StringUtils.randomAlphabetic(300)); + + assertThatThrownBy(() -> apiKeyService.saveApiKey(tenantId, apiKeyInfo)) + .isInstanceOf(DataValidationException.class) + .hasMessageContaining("description length must be equal or less than 255"); + } + + @Test + public void testUpdateDescriptionApiKey() { + ApiKeyInfo apiKeyInfo = createApiKeyInfo(TEST_API_KEY_DESCRIPTION); + ApiKey savedApiKey = apiKeyService.saveApiKey(tenantId, apiKeyInfo); + + String newDescription = "Updated API Key Description"; + savedApiKey.setDescription(newDescription); + ApiKey updatedApiKey = apiKeyService.saveApiKey(tenantId, savedApiKey); + + Assert.assertNotNull(updatedApiKey); + Assert.assertEquals(savedApiKey.getId(), updatedApiKey.getId()); + Assert.assertEquals(newDescription, updatedApiKey.getDescription()); + Assert.assertEquals(savedApiKey.getHash(), updatedApiKey.getHash()); + } + + @Test + public void testDisableApiKey() { + ApiKeyInfo apiKeyInfo = createApiKeyInfo(TEST_API_KEY_DESCRIPTION); + ApiKey savedApiKey = apiKeyService.saveApiKey(tenantId, apiKeyInfo); + + savedApiKey.setEnabled(false); + ApiKey disabledApiKey = apiKeyService.saveApiKey(tenantId, savedApiKey); + + Assert.assertNotNull(disabledApiKey); + Assert.assertEquals(savedApiKey.getId(), disabledApiKey.getId()); + Assert.assertFalse(disabledApiKey.isEnabled()); + } + + @Test + public void testFindApiKeyById() { + ApiKeyInfo apiKeyInfo = createApiKeyInfo(TEST_API_KEY_DESCRIPTION); + ApiKey savedApiKey = apiKeyService.saveApiKey(tenantId, apiKeyInfo); + + ApiKey foundApiKey = apiKeyService.findApiKeyById(tenantId, savedApiKey.getId()); + + Assert.assertNotNull(foundApiKey); + Assert.assertEquals(savedApiKey.getId(), foundApiKey.getId()); + Assert.assertEquals(savedApiKey.getDescription(), foundApiKey.getDescription()); + Assert.assertEquals(savedApiKey.isEnabled(), foundApiKey.isEnabled()); + Assert.assertEquals(savedApiKey.getHash(), foundApiKey.getHash()); + } + + @Test + public void testFindApiKeyByHash() { + ApiKeyInfo apiKeyInfo = createApiKeyInfo(TEST_API_KEY_DESCRIPTION); + ApiKey savedApiKey = apiKeyService.saveApiKey(tenantId, apiKeyInfo); + + ApiKey foundApiKey = apiKeyService.findApiKeyByHash(savedApiKey.getHash()); + + Assert.assertNotNull(foundApiKey); + Assert.assertEquals(savedApiKey.getId(), foundApiKey.getId()); + Assert.assertEquals(savedApiKey.getDescription(), foundApiKey.getDescription()); + Assert.assertEquals(savedApiKey.isEnabled(), foundApiKey.isEnabled()); + Assert.assertEquals(savedApiKey.getHash(), foundApiKey.getHash()); + } + + @Test + public void testFindApiKeysByUserId() { + int size = 3; + for (int i = 0; i < size; i++) { + ApiKeyInfo apiKeyInfo = createApiKeyInfo("API Key " + i); + apiKeyService.saveApiKey(tenantId, apiKeyInfo); + } + + PageLink pageLink = new PageLink(10); + PageData pageData = apiKeyService.findApiKeysByUserId(tenantId, userId, pageLink); + + Assert.assertNotNull(pageData); + Assert.assertEquals(size, pageData.getData().size()); + Assert.assertEquals(size, pageData.getTotalElements()); + } + + @Test + public void testDeleteApiKey() { + ApiKeyInfo apiKeyInfo = createApiKeyInfo(TEST_API_KEY_DESCRIPTION); + ApiKey savedApiKey = apiKeyService.saveApiKey(tenantId, apiKeyInfo); + + apiKeyService.deleteApiKey(tenantId, savedApiKey, false); + + ApiKey foundApiKey = apiKeyService.findApiKeyById(tenantId, savedApiKey.getId()); + Assert.assertNull(foundApiKey); + } + + @Test + public void testDeleteByTenantId() { + // Create 3 API keys for the user + for (int i = 0; i < 3; i++) { + ApiKeyInfo apiKeyInfo = createApiKeyInfo("API Key " + i); + apiKeyService.saveApiKey(tenantId, apiKeyInfo); + } + + apiKeyService.deleteByTenantId(tenantId); + + PageLink pageLink = new PageLink(10); + PageData pageData = apiKeyService.findApiKeysByUserId(tenantId, userId, pageLink); + + Assert.assertNotNull(pageData); + Assert.assertEquals(0, pageData.getData().size()); + Assert.assertEquals(0, pageData.getTotalElements()); + } + + @Test + public void testDeleteByUserId() { + int size = 3; + for (int i = 0; i < size; i++) { + ApiKeyInfo apiKeyInfo = createApiKeyInfo("API Key " + i); + apiKeyService.saveApiKey(tenantId, apiKeyInfo); + } + + PageData pageData = apiKeyService.findApiKeysByUserId(tenantId, userId, new PageLink(10)); + Assert.assertNotNull(pageData); + Assert.assertEquals(size, pageData.getData().size()); + Assert.assertEquals(size, pageData.getTotalElements()); + + // delete by user id + apiKeyService.deleteByUserId(tenantId, userId); + + pageData = apiKeyService.findApiKeysByUserId(tenantId, userId, new PageLink(10)); + Assert.assertNotNull(pageData); + Assert.assertEquals(0, pageData.getData().size()); + Assert.assertEquals(0, pageData.getTotalElements()); + } + + private ApiKeyInfo createApiKeyInfo(String description) { + ApiKeyInfo apiKeyInfo = new ApiKeyInfo(); + apiKeyInfo.setTenantId(tenantId); + apiKeyInfo.setUserId(userId); + apiKeyInfo.setDescription(description); + apiKeyInfo.setEnabled(true); + return apiKeyInfo; + } + +} diff --git a/dao/src/test/resources/application-test.properties b/dao/src/test/resources/application-test.properties index 1c2c0c5519..de4b342af5 100644 --- a/dao/src/test/resources/application-test.properties +++ b/dao/src/test/resources/application-test.properties @@ -114,6 +114,9 @@ cache.specs.trendzSettings.maxSize=10000 cache.specs.aiModel.timeToLiveInMinutes=1440 cache.specs.aiModel.maxSize=10000 +cache.specs.apiKeys.timeToLiveInMinutes=1440 +cache.specs.apiKeys.maxSize=10000 + redis.connection.host=localhost redis.connection.port=6379 redis.connection.db=0 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 0e559efd46..fcc83e1149 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 @@ -19,6 +19,7 @@ import com.auth0.jwt.JWT; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.base.Strings; +import lombok.Getter; import lombok.SneakyThrows; import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.concurrent.LazyInitializer; @@ -203,16 +204,15 @@ import java.util.stream.Collectors; import static org.thingsboard.server.common.data.StringUtils.isEmpty; -/** - * @author Andrew Shvayka - */ public class RestClient implements Closeable { - private static final String JWT_TOKEN_HEADER_PARAM = "X-Authorization"; + + private static final String TOKEN_HEADER_PARAM = "X-Authorization"; private static final long AVG_REQUEST_TIMEOUT = TimeUnit.SECONDS.toMillis(30); protected static final String ACTIVATE_TOKEN_REGEX = "/api/noauth/activate?activateToken="; private final LazyInitializer executor = LazyInitializer.builder() .setInitializer(() -> ThingsBoardExecutors.newWorkStealingPool(10, getClass())) .get(); + @Getter protected final RestTemplate restTemplate; protected final RestTemplate loginRestTemplate; protected final String baseURL; @@ -220,58 +220,64 @@ public class RestClient implements Closeable { private String username; private String password; private String mainToken; + @Getter private String refreshToken; private long mainTokenExpTs; private long refreshTokenExpTs; private long clientServerTimeDiff; + public enum AuthType { JWT, API_KEY } + public RestClient(String baseURL) { this(new RestTemplate(), baseURL); } public RestClient(RestTemplate restTemplate, String baseURL) { - this(restTemplate, baseURL, null); + this(restTemplate, baseURL, AuthType.JWT, null); } - public RestClient(RestTemplate restTemplate, String baseURL, String accessToken) { + public RestClient(RestTemplate restTemplate, String baseURL, AuthType authType, String token) { this.restTemplate = restTemplate; this.loginRestTemplate = new RestTemplate(restTemplate.getRequestFactory()); this.baseURL = baseURL; this.restTemplate.getInterceptors().add((request, bytes, execution) -> { HttpRequest wrapper = new HttpRequestWrapper(request); - if (accessToken == null) { - long calculatedTs = System.currentTimeMillis() + clientServerTimeDiff + AVG_REQUEST_TIMEOUT; - if (calculatedTs > mainTokenExpTs) { - synchronized (RestClient.this) { + switch (authType) { + case JWT -> { + if (token == null) { + long calculatedTs = System.currentTimeMillis() + clientServerTimeDiff + AVG_REQUEST_TIMEOUT; if (calculatedTs > mainTokenExpTs) { - if (calculatedTs < refreshTokenExpTs) { - refreshToken(); - } else { - doLogin(); + synchronized (RestClient.this) { + if (calculatedTs > mainTokenExpTs) { + if (calculatedTs < refreshTokenExpTs) { + refreshToken(); + } else { + doLogin(); + } + } } } + } else { + mainToken = token; } + wrapper.getHeaders().set(TOKEN_HEADER_PARAM, "Bearer " + mainToken); + } + case API_KEY -> { + wrapper.getHeaders().set(TOKEN_HEADER_PARAM, "ApiKey " + token); } - } else { - mainToken = accessToken; } - wrapper.getHeaders().set(JWT_TOKEN_HEADER_PARAM, "Bearer " + mainToken); return execution.execute(wrapper, bytes); }); } - public RestTemplate getRestTemplate() { - return restTemplate; + public static RestClient withApiKey(RestTemplate rt, String baseURL, String token) { + return new RestClient(rt, baseURL, AuthType.API_KEY, token); } public String getToken() { return mainToken; } - public String getRefreshToken() { - return refreshToken; - } - public void refreshToken() { Map refreshTokenRequest = new HashMap<>(); refreshTokenRequest.put("refreshToken", refreshToken); From 399def92de428fab7e60047b5d7d24f3a2c0a29b Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Mon, 29 Sep 2025 15:33:23 +0300 Subject: [PATCH 02/56] Add Sysadmin to controller authority, minor changes, add cleanup service to delete expired api keys --- .../server/controller/ApiKeyController.java | 23 ++++--- .../extractor/ApiKeyHeaderTokenExtractor.java | 3 +- .../settings/InstallJwtSettingsValidator.java | 2 +- .../pat/ApiKeyAuthenticationProvider.java | 5 +- .../permission/SysAdminPermissions.java | 1 + .../permission/TenantAdminPermissions.java | 2 +- .../service/ttl/ApiKeysCleanUpService.java | 62 +++++++++++++++++++ .../src/main/resources/thingsboard.yml | 4 ++ .../controller/ApiKeyControllerTest.java | 1 + .../pat/ApiKeyAuthenticationProviderTest.java | 2 +- .../server/common/data/EntityType.java | 7 +-- .../server/common/data/pat/ApiKeyInfo.java | 2 +- .../thingsboard/server/dao/pat/ApiKeyDao.java | 2 + .../validator/ApiKeyDataValidator.java | 4 +- .../dao/sql/pat/ApiKeyInfoRepository.java | 2 +- .../server/dao/sql/pat/ApiKeyRepository.java | 5 ++ .../server/dao/sql/pat/JpaApiKeyDao.java | 5 ++ .../main/resources/sql/schema-entities.sql | 2 +- .../server/dao/service/ApiKeyServiceTest.java | 2 - 19 files changed, 104 insertions(+), 32 deletions(-) create mode 100644 application/src/main/java/org/thingsboard/server/service/ttl/ApiKeysCleanUpService.java diff --git a/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java b/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java index b0afa23dcd..81aca136fe 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java +++ b/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java @@ -51,7 +51,7 @@ import static org.thingsboard.server.controller.ControllerConstants.API_KEY_ID_P import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; -import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH; import static org.thingsboard.server.controller.ControllerConstants.USER_ID_PARAM_DESCRIPTION; @RestController @@ -64,23 +64,22 @@ public class ApiKeyController extends BaseController { private final ApiKeyService apiKeyService; @ApiOperation(value = "Save API key for user (saveApiKey)", - notes = "Creates an API key for the given user and returns the token ONCE as 'ApiKey '." + TENANT_AUTHORITY_PARAGRAPH) - @PreAuthorize("hasAuthority('TENANT_ADMIN')") + notes = "Creates an API key for the given user and returns the token ONCE as 'ApiKey '." + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN','TENANT_ADMIN')") @PostMapping(value = "/apiKey") public String saveApiKey( @Parameter(description = "A JSON value representing the Api Key token.") @RequestBody @Valid ApiKeyInfo apiKeyInfo) throws ThingsboardException { SecurityUser securityUser = getCurrentUser(); apiKeyInfo.setTenantId(securityUser.getTenantId()); - apiKeyInfo.setUserId(securityUser.getId()); checkEntity(apiKeyInfo.getId(), apiKeyInfo, Resource.API_KEY); return toUserApiKey(checkNotNull(apiKeyService.saveApiKey(securityUser.getTenantId(), apiKeyInfo)).getHash()); } @ApiOperation(value = "Get User Api Keys (getUserApiKeys)", notes = "Returns a page of api keys owned by user. " + - PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) - @PreAuthorize("hasAuthority('TENANT_ADMIN')") + PAGE_DATA_PARAMETERS + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN','TENANT_ADMIN')") @GetMapping(value = "/apiKeys/{userId}") public PageData getUserApiKeys( @Parameter(description = USER_ID_PARAM_DESCRIPTION) @@ -99,8 +98,8 @@ public class ApiKeyController extends BaseController { @ApiOperation(value = "Update API key Description", notes = "Updates the description of the existing API key by apiKeyId. " + "Only the description can be updated. " + - "Referencing a non-existing ApiKey Id will cause a 'Not Found' error." + TENANT_AUTHORITY_PARAGRAPH) - @PreAuthorize("hasAuthority('TENANT_ADMIN')") + "Referencing a non-existing ApiKey Id will cause a 'Not Found' error." + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN','TENANT_ADMIN')") @PutMapping("/apiKey/{id}/description") public ApiKeyInfo updateApiKeyDescription( @Parameter(description = API_KEY_ID_PARAM_DESCRIPTION, required = true) @@ -114,8 +113,8 @@ public class ApiKeyController extends BaseController { } @ApiOperation(value = "Enable or disable API key (enableApiKey)", - notes = "Updates api key with enabled = true/false. " + TENANT_AUTHORITY_PARAGRAPH) - @PreAuthorize("hasAuthority('TENANT_ADMIN')") + notes = "Updates api key with enabled = true/false. " + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN','TENANT_ADMIN')") @PutMapping(value = "/apiKey/{id}/enabled/{enabledValue}") public ApiKeyInfo enableApiKey( @Parameter(description = "Unique identifier of the API key to enable/disable", required = true) @@ -129,8 +128,8 @@ public class ApiKeyController extends BaseController { } @ApiOperation(value = "Delete API key by ID (deleteApiKey)", - notes = "Deletes the API key. Referencing non-existing ApiKey Id will cause an error." + TENANT_AUTHORITY_PARAGRAPH) - @PreAuthorize("hasAuthority('TENANT_ADMIN')") + notes = "Deletes the API key. Referencing non-existing ApiKey Id will cause an error." + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN','TENANT_ADMIN')") @DeleteMapping(value = "/apiKey/{id}") public void deleteApiKey(@PathVariable UUID id) throws ThingsboardException { ApiKeyId apiKeyId = new ApiKeyId(id); diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/extractor/ApiKeyHeaderTokenExtractor.java b/application/src/main/java/org/thingsboard/server/service/security/auth/extractor/ApiKeyHeaderTokenExtractor.java index 9aec92b8c5..11f95e1711 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/extractor/ApiKeyHeaderTokenExtractor.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/extractor/ApiKeyHeaderTokenExtractor.java @@ -20,8 +20,7 @@ import org.springframework.stereotype.Component; import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.API_KEY_HEADER_PREFIX; -@Component -@Qualifier("apiKeyHeaderTokenExtractor") +@Component(value = "apiKeyHeaderTokenExtractor") public class ApiKeyHeaderTokenExtractor extends AbstractHeaderTokenExtractor { public ApiKeyHeaderTokenExtractor() { diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/InstallJwtSettingsValidator.java b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/InstallJwtSettingsValidator.java index fdfd1a903d..cd9bfeb674 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/InstallJwtSettingsValidator.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/InstallJwtSettingsValidator.java @@ -22,7 +22,7 @@ import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.security.model.JwtSettings; /** - * During Install or upgrade the validation is suppressed to keep existing data + * During Install or upgrade, the validation is suppressed to keep existing data * */ @Primary @Profile("install") diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProvider.java index 6bc9c50a66..e0702d7440 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProvider.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProvider.java @@ -17,6 +17,7 @@ package org.thingsboard.server.service.security.auth.pat; import lombok.RequiredArgsConstructor; import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.CredentialsExpiredException; import org.springframework.security.authentication.DisabledException; import org.springframework.security.authentication.InsufficientAuthenticationException; import org.springframework.security.core.Authentication; @@ -60,13 +61,13 @@ public class ApiKeyAuthenticationProvider implements org.springframework.securit } ApiKey apiKey = apiKeyService.findApiKeyByHash(key); if (apiKey == null) { - throw new UsernameNotFoundException("User not found for the provided API key"); + throw new BadCredentialsException("User not found for the provided API key"); } if (!apiKey.isEnabled()) { throw new DisabledException("API key auth is not active"); } if (apiKey.getExpirationTime() != 0 && apiKey.getExpirationTime() < System.currentTimeMillis()) { - throw new BadCredentialsException("API key is expired"); + throw new CredentialsExpiredException("API key is expired"); } TenantId tenantId = apiKey.getTenantId(); UserId userId = apiKey.getUserId(); diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/SysAdminPermissions.java b/application/src/main/java/org/thingsboard/server/service/security/permission/SysAdminPermissions.java index 6bd7aacf54..d98be852f6 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/permission/SysAdminPermissions.java +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/SysAdminPermissions.java @@ -45,6 +45,7 @@ public class SysAdminPermissions extends AbstractPermissions { put(Resource.QUEUE, systemEntityPermissionChecker); put(Resource.NOTIFICATION, systemEntityPermissionChecker); put(Resource.MOBILE_APP_SETTINGS, PermissionChecker.allowAllPermissionChecker); + put(Resource.API_KEY, systemEntityPermissionChecker); } private static final PermissionChecker systemEntityPermissionChecker = new PermissionChecker() { diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java b/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java index 33514dcf99..2700829af5 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java @@ -176,7 +176,7 @@ public class TenantAdminPermissions extends AbstractPermissions { @Override public boolean hasPermission(SecurityUser user, Operation operation, ApiKeyId entityId, ApiKeyInfo entity) { - return user.getTenantId().equals(entity.getTenantId()); + return user.getId().equals(entity.getUserId()); } }; diff --git a/application/src/main/java/org/thingsboard/server/service/ttl/ApiKeysCleanUpService.java b/application/src/main/java/org/thingsboard/server/service/ttl/ApiKeysCleanUpService.java new file mode 100644 index 0000000000..b394b93c09 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/ttl/ApiKeysCleanUpService.java @@ -0,0 +1,62 @@ +/** + * Copyright © 2016-2025 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.service.ttl; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.thingsboard.server.dao.pat.ApiKeyDao; +import org.thingsboard.server.queue.discovery.PartitionService; +import org.thingsboard.server.queue.util.TbCoreComponent; + +import java.util.concurrent.TimeUnit; + +@Slf4j +@Service +@TbCoreComponent +@ConditionalOnExpression("${sql.ttl.api_keys.enabled:true} && ${sql.ttl.api_keys.ttl:0} > 0") +public class ApiKeysCleanUpService extends AbstractCleanUpService { + + public static final String RANDOM_DELAY_INTERVAL_MS_EXPRESSION = + "#{T(org.apache.commons.lang3.RandomUtils).nextLong(0, ${sql.ttl.api_keys.checking_interval_ms})}"; + + @Value("${sql.ttl.api_keys.ttl:2592000}") + private long ttl; + + private final ApiKeyDao apiKeyDao; + + public ApiKeysCleanUpService(PartitionService partitionService, ApiKeyDao apiKeyDao) { + super(partitionService); + this.apiKeyDao = apiKeyDao; + } + + @Scheduled( + initialDelayString = RANDOM_DELAY_INTERVAL_MS_EXPRESSION, + fixedDelayString = "${sql.ttl.api_keys.checking_interval_ms:86400000}" + ) + public void cleanUp() { + long threshold = System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(ttl); + if (isSystemTenantPartitionMine()) { + int deleted = apiKeyDao.deleteAllByExpirationTimeBefore(threshold); + if (deleted > 0) { + log.info("API key cleanup removed {} keys (thresholdTs={})", deleted, threshold); + } + } + } + +} diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 815a6c0026..a77b8c800e 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -427,6 +427,10 @@ sql: enabled: "${SQL_TTL_NOTIFICATIONS_ENABLED:true}" # Enable/disable TTL (Time To Live) for notification center records ttl: "${SQL_TTL_NOTIFICATIONS_SECS:2592000}" # Default value - 30 days checking_interval_ms: "${SQL_TTL_NOTIFICATIONS_CHECKING_INTERVAL_MS:86400000}" # Default value - 1 day + api_keys: + enabled: "${SQL_TTL_API_KEYS_ENABLED:true}" # Enable/disable TTL (Time To Live) for api keys records + ttl: "${SQL_TTL_API_KEYS_SECS:2592000}" # Default value - 30 days + checking_interval_ms: "${SQL_TTL_API_KEYS_CHECKING_INTERVAL_MS:86400000}" # Default value - 1 day relations: max_level: "${SQL_RELATIONS_MAX_LEVEL:50}" # This value has to be reasonably small to prevent infinite recursion as early as possible pool_size: "${SQL_RELATIONS_POOL_SIZE:4}" # This value has to be reasonably small to prevent the relation query from blocking all other DB calls diff --git a/application/src/test/java/org/thingsboard/server/controller/ApiKeyControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/ApiKeyControllerTest.java index 156ec5baa3..b293d68c03 100644 --- a/application/src/test/java/org/thingsboard/server/controller/ApiKeyControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/ApiKeyControllerTest.java @@ -138,6 +138,7 @@ public class ApiKeyControllerTest extends AbstractControllerTest { ApiKeyInfo apiKeyInfo = new ApiKeyInfo(); apiKeyInfo.setDescription(description); apiKeyInfo.setEnabled(enabled); + apiKeyInfo.setUserId(tenantAdminUserId); return apiKeyInfo; } diff --git a/application/src/test/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProviderTest.java b/application/src/test/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProviderTest.java index a0f07549e7..367e47dc85 100644 --- a/application/src/test/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProviderTest.java +++ b/application/src/test/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProviderTest.java @@ -48,7 +48,7 @@ import static org.mockito.Mockito.when; public class ApiKeyAuthenticationProviderTest { private static final String TEST_API_KEY = "test_api_key"; - private static final String USER_EMAIL = "test@example.com"; + private static final String USER_EMAIL = "tenant@thingsboard.org"; @Mock private ApiKeyService apiKeyService; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java b/common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java index b4cba03d81..39f60e6b9f 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java @@ -71,12 +71,7 @@ public enum EntityType { return "AI model"; } }, - API_KEY(44, "api_key") { - @Override - public String getNormalName() { - return "API key"; - } - }; + API_KEY(44); @Getter private final int protoNumber; // Corresponds to EntityTypeProto diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/pat/ApiKeyInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/pat/ApiKeyInfo.java index 25c0ddce1c..3bfb53ca0f 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/pat/ApiKeyInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/pat/ApiKeyInfo.java @@ -39,7 +39,7 @@ public class ApiKeyInfo extends BaseData implements HasTenantId { @Schema(description = "JSON object with Tenant Id. Tenant Id of the api key cannot be changed.", accessMode = Schema.AccessMode.READ_ONLY) private TenantId tenantId; - @Schema(description = "JSON object with User Id. User Id of the api key cannot be changed.", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "JSON object with User Id. User Id of the api key cannot be changed.") private UserId userId; @Schema(description = "Expiration time of the api key.") diff --git a/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyDao.java b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyDao.java index a48a746cbd..ae45fbafe5 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyDao.java @@ -30,4 +30,6 @@ public interface ApiKeyDao extends Dao { Set deleteByUserId(TenantId tenantId, UserId userId); + int deleteAllByExpirationTimeBefore(long ts); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/ApiKeyDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/ApiKeyDataValidator.java index b65f651801..a1431846f7 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/ApiKeyDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/ApiKeyDataValidator.java @@ -47,14 +47,14 @@ public class ApiKeyDataValidator extends DataValidator { if (apiKey.getTenantId() == null || apiKey.getTenantId().getId() == null) { throw new DataValidationException("API key should be assigned to tenant!"); } - if (tenantDao.findById(apiKey.getTenantId(), apiKey.getTenantId().getId()) == null) { + if (!TenantId.SYS_TENANT_ID.equals(apiKey.getTenantId()) && tenantDao.findById(apiKey.getTenantId(), apiKey.getTenantId().getId()) == null) { throw new DataValidationException("API key reference a non-existent tenant!"); } if (apiKey.getUserId() == null || apiKey.getUserId().getId() == null) { throw new DataValidationException("API key should be assigned to user!"); } - if (userDao.findById(tenantId, apiKey.getUserId().getId()) == null) { + if (userDao.findById(apiKey.getTenantId(), apiKey.getUserId().getId()) == null) { throw new DataValidationException("API key reference a non-existent user!"); } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyInfoRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyInfoRepository.java index f5a249ead2..cc15a08fb1 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyInfoRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyInfoRepository.java @@ -26,7 +26,7 @@ import java.util.UUID; public interface ApiKeyInfoRepository extends JpaRepository { - @Query("SELECT k FROM ApiKeyInfoEntity k WHERE k.tenantId = :tenantId AND k.userId = :userId") + @Query("SELECT ak FROM ApiKeyInfoEntity ak WHERE ak.tenantId = :tenantId AND ak.userId = :userId") Page findByUserId(@Param("tenantId") UUID tenantId, @Param("userId") UUID userId, Pageable pageable); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyRepository.java index d744b22ee9..f175e6f91c 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyRepository.java @@ -50,4 +50,9 @@ public interface ApiKeyRepository extends JpaRepository { Set deleteByUserId(@Param("tenantId") UUID tenantId, @Param("userId") UUID userId); + @Transactional + @Modifying + @Query("DELETE FROM ApiKeyEntity ak WHERE ak.expirationTime > 0 AND ak.expirationTime < :ts") + int deleteAllByExpirationTimeBefore(@Param("ts") long ts); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/pat/JpaApiKeyDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/pat/JpaApiKeyDao.java index 185a3ac951..8a3226655d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/pat/JpaApiKeyDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/pat/JpaApiKeyDao.java @@ -55,6 +55,11 @@ public class JpaApiKeyDao extends JpaAbstractDao implement return apiKeyRepository.deleteByUserId(tenantId.getId(), userId.getId()); } + @Override + public int deleteAllByExpirationTimeBefore(long ts) { + return apiKeyRepository.deleteAllByExpirationTimeBefore(ts); + } + @Override protected Class getEntityClass() { return ApiKeyEntity.class; diff --git a/dao/src/main/resources/sql/schema-entities.sql b/dao/src/main/resources/sql/schema-entities.sql index 31eb6e9d1b..d730b3283d 100644 --- a/dao/src/main/resources/sql/schema-entities.sql +++ b/dao/src/main/resources/sql/schema-entities.sql @@ -716,7 +716,7 @@ CREATE TABLE IF NOT EXISTS api_key ( user_id uuid, hash varchar(255), enabled boolean NOT NULL DEFAULT TRUE, - expiration_time bigint, + expiration_time bigint DEFAULT 0, description varchar(1024), CONSTRAINT api_hash_unq_key UNIQUE (hash) ); diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/ApiKeyServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/ApiKeyServiceTest.java index 96bc7187ed..9acd22a193 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/ApiKeyServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/ApiKeyServiceTest.java @@ -184,7 +184,6 @@ public class ApiKeyServiceTest extends AbstractServiceTest { @Test public void testDeleteByTenantId() { - // Create 3 API keys for the user for (int i = 0; i < 3; i++) { ApiKeyInfo apiKeyInfo = createApiKeyInfo("API Key " + i); apiKeyService.saveApiKey(tenantId, apiKeyInfo); @@ -213,7 +212,6 @@ public class ApiKeyServiceTest extends AbstractServiceTest { Assert.assertEquals(size, pageData.getData().size()); Assert.assertEquals(size, pageData.getTotalElements()); - // delete by user id apiKeyService.deleteByUserId(tenantId, userId); pageData = apiKeyService.findApiKeysByUserId(tenantId, userId, new PageLink(10)); From 91f5cf03e7ed2f3e7262172648961c0b7fecfa9e Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Mon, 29 Sep 2025 16:05:30 +0300 Subject: [PATCH 03/56] Remove method deleteByTenantId - already exists --- .../main/java/org/thingsboard/server/dao/pat/ApiKeyService.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/pat/ApiKeyService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/pat/ApiKeyService.java index 091d23bf92..375dce2cd8 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/pat/ApiKeyService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/pat/ApiKeyService.java @@ -30,8 +30,6 @@ public interface ApiKeyService extends EntityDaoService { void deleteApiKey(TenantId tenantId, ApiKey apiKey, boolean force); - void deleteByTenantId(TenantId tenantId); - void deleteByUserId(TenantId tenantId, UserId userId); ApiKey findApiKeyByHash(String hash); From 49d3022d476251ddfa5d5a61a930193cd71e3836 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Mon, 29 Sep 2025 16:37:35 +0300 Subject: [PATCH 04/56] Refactor saveApiKey inside Controller --- .../server/controller/ApiKeyController.java | 6 ++++-- .../server/controller/ApiKeyControllerTest.java | 11 ++++++----- .../thingsboard/server/common/data/pat/ApiKey.java | 2 -- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java b/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java index 81aca136fe..deb0628988 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java +++ b/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java @@ -67,13 +67,15 @@ public class ApiKeyController extends BaseController { notes = "Creates an API key for the given user and returns the token ONCE as 'ApiKey '." + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN','TENANT_ADMIN')") @PostMapping(value = "/apiKey") - public String saveApiKey( + public ApiKey saveApiKey( @Parameter(description = "A JSON value representing the Api Key token.") @RequestBody @Valid ApiKeyInfo apiKeyInfo) throws ThingsboardException { SecurityUser securityUser = getCurrentUser(); apiKeyInfo.setTenantId(securityUser.getTenantId()); checkEntity(apiKeyInfo.getId(), apiKeyInfo, Resource.API_KEY); - return toUserApiKey(checkNotNull(apiKeyService.saveApiKey(securityUser.getTenantId(), apiKeyInfo)).getHash()); + ApiKey savedApiKey = checkNotNull(apiKeyService.saveApiKey(securityUser.getTenantId(), apiKeyInfo)); + savedApiKey.setHash(toUserApiKey(savedApiKey.getHash())); + return savedApiKey; } @ApiOperation(value = "Get User Api Keys (getUserApiKeys)", diff --git a/application/src/test/java/org/thingsboard/server/controller/ApiKeyControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/ApiKeyControllerTest.java index b293d68c03..8b9e7cae8c 100644 --- a/application/src/test/java/org/thingsboard/server/controller/ApiKeyControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/ApiKeyControllerTest.java @@ -21,6 +21,7 @@ import org.junit.Before; import org.junit.Test; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.pat.ApiKey; import org.thingsboard.server.common.data.pat.ApiKeyInfo; import org.thingsboard.server.dao.service.DaoSqlTest; @@ -41,7 +42,7 @@ public class ApiKeyControllerTest extends AbstractControllerTest { public void testSaveApiKey() throws Exception { ApiKeyInfo apiKeyInfo = constructApiKeyInfo("New API key description", true); - String apiKeyStr = doPost("/api/apiKey", apiKeyInfo, String.class); + String apiKeyStr = doPost("/api/apiKey", apiKeyInfo, ApiKey.class).getHash(); Assert.assertTrue(apiKeyStr.startsWith(API_KEY_HEADER_PREFIX)); PageData pageData = doGetTypedWithPageLink("/api/apiKeys/" + tenantAdminUserId + "?", new TypeReference<>() {}, new PageLink(10, 0)); @@ -65,7 +66,7 @@ public class ApiKeyControllerTest extends AbstractControllerTest { ApiKeyInfo apiKeyInfo = constructApiKeyInfo("Test API key description", true); int expectedSize = 10; for (int i = 0; i < expectedSize; i++) { - doPost("/api/apiKey", apiKeyInfo, String.class); + doPost("/api/apiKey", apiKeyInfo, ApiKey.class); } PageData pageData2 = doGetTypedWithPageLink("/api/apiKeys/" + tenantAdminUserId + "?", new TypeReference<>() {}, new PageLink(10, 0)); @@ -83,7 +84,7 @@ public class ApiKeyControllerTest extends AbstractControllerTest { @Test public void testUpdateApiKeyDescription() throws Exception { ApiKeyInfo apiKeyInfo = constructApiKeyInfo("Test API key description", true); - doPost("/api/apiKey", apiKeyInfo, String.class); + doPost("/api/apiKey", apiKeyInfo, ApiKey.class); PageData pageData = doGetTypedWithPageLink("/api/apiKeys/" + tenantAdminUserId + "?", new TypeReference<>() {}, new PageLink(10, 0)); Assert.assertEquals(1, pageData.getData().size()); @@ -102,7 +103,7 @@ public class ApiKeyControllerTest extends AbstractControllerTest { @Test public void testEnableApiKey() throws Exception { ApiKeyInfo apiKeyInfo = constructApiKeyInfo("Test API key description", true); - doPost("/api/apiKey", apiKeyInfo, String.class); + doPost("/api/apiKey", apiKeyInfo, ApiKey.class); PageData pageData = doGetTypedWithPageLink("/api/apiKeys/" + tenantAdminUserId + "?", new TypeReference<>() {}, new PageLink(10, 0)); Assert.assertEquals(1, pageData.getData().size()); @@ -125,7 +126,7 @@ public class ApiKeyControllerTest extends AbstractControllerTest { doDelete("/api/apiKey/" + UUID.randomUUID()).andExpect(status().isNotFound()); ApiKeyInfo apiKeyInfo = constructApiKeyInfo("Test API key description", false); - doPost("/api/apiKey", apiKeyInfo, String.class); + doPost("/api/apiKey", apiKeyInfo, ApiKey.class); PageData pageData = doGetTypedWithPageLink("/api/apiKeys/" + tenantAdminUserId + "?", new TypeReference<>() {}, new PageLink(10, 0)); Assert.assertEquals(1, pageData.getData().size()); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/pat/ApiKey.java b/common/data/src/main/java/org/thingsboard/server/common/data/pat/ApiKey.java index 7188ed3ef5..82d88f1ae7 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/pat/ApiKey.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/pat/ApiKey.java @@ -15,7 +15,6 @@ */ package org.thingsboard.server.common.data.pat; -import com.fasterxml.jackson.annotation.JsonIgnore; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; @@ -34,7 +33,6 @@ public class ApiKey extends ApiKeyInfo { @NoXss @Schema(description = "Api key hash value", requiredMode = Schema.RequiredMode.REQUIRED) - @JsonIgnore private String hash; public ApiKey() { From 8d6ce9d05a68d80e76991c661a440d11c951db96 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Mon, 29 Sep 2025 16:42:18 +0300 Subject: [PATCH 05/56] Cleanup impoort --- .../security/auth/extractor/ApiKeyHeaderTokenExtractor.java | 1 - .../java/org/thingsboard/server/dao/user/UserServiceImpl.java | 1 - 2 files changed, 2 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/extractor/ApiKeyHeaderTokenExtractor.java b/application/src/main/java/org/thingsboard/server/service/security/auth/extractor/ApiKeyHeaderTokenExtractor.java index 11f95e1711..34f8b91414 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/extractor/ApiKeyHeaderTokenExtractor.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/extractor/ApiKeyHeaderTokenExtractor.java @@ -15,7 +15,6 @@ */ package org.thingsboard.server.service.security.auth.extractor; -import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Component; import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.API_KEY_HEADER_PREFIX; diff --git a/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java index ac5efc1888..88973b3f6c 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java @@ -58,7 +58,6 @@ import org.thingsboard.server.dao.eventsourcing.ActionEntityEvent; import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent; import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; import org.thingsboard.server.dao.exception.IncorrectParameterException; -import org.thingsboard.server.dao.pat.ApiKeyDao; import org.thingsboard.server.dao.pat.ApiKeyService; import org.thingsboard.server.dao.service.DataValidator; import org.thingsboard.server.dao.service.PaginatedRemover; From 7b114d909229c1ae3871c94bbc323321ffed68b2 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Mon, 29 Sep 2025 18:32:48 +0300 Subject: [PATCH 06/56] Remove cache --- .../src/main/resources/thingsboard.yml | 3 -- .../server/common/data/CacheConstants.java | 1 - .../server/dao/pat/ApiKeyCacheKey.java | 40 ------------------- .../server/dao/pat/ApiKeyCaffeineCache.java | 33 --------------- .../thingsboard/server/dao/pat/ApiKeyDao.java | 4 +- .../server/dao/pat/ApiKeyEvictEvent.java | 19 --------- .../server/dao/pat/ApiKeyRedisCache.java | 36 ----------------- .../server/dao/pat/ApiKeyServiceImpl.java | 30 +++----------- .../server/dao/sql/pat/ApiKeyRepository.java | 18 ++------- .../server/dao/sql/pat/JpaApiKeyDao.java | 8 ++-- 10 files changed, 16 insertions(+), 176 deletions(-) delete mode 100644 dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyCacheKey.java delete mode 100644 dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyCaffeineCache.java delete mode 100644 dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyEvictEvent.java delete mode 100644 dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyRedisCache.java diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index a77b8c800e..361c584492 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -666,9 +666,6 @@ cache: aiModel: timeToLiveInMinutes: "${CACHE_SPECS_AI_MODEL_TTL:1440}" # AI model cache TTL maxSize: "${CACHE_SPECS_AI_MODEL_MAX_SIZE:10000}" # 0 means the cache is disabled - apiKeys: - timeToLiveInMinutes: "${CACHE_SPECS_API_KEYS_TTL:1440}" # API keys cache TTL - maxSize: "${CACHE_SPECS_API_KEYS_MAX_SIZE:10000}" # 0 means the cache is disabled # Deliberately placed outside the 'specs' group above notificationRules: diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java b/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java index c97a3a9a21..b55453f393 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java @@ -40,7 +40,6 @@ public final class CacheConstants { public static final String SENT_NOTIFICATIONS_CACHE = "sentNotifications"; public static final String TRENDZ_SETTINGS_CACHE = "trendzSettings"; public static final String AI_MODEL_CACHE = "aiModel"; - public static final String API_KEYS_CACHE = "apiKeys"; public static final String ASSET_PROFILE_CACHE = "assetProfiles"; public static final String ATTRIBUTES_CACHE = "attributes"; diff --git a/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyCacheKey.java b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyCacheKey.java deleted file mode 100644 index d01cc84efb..0000000000 --- a/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyCacheKey.java +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Copyright © 2016-2025 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.dao.pat; - -import org.checkerframework.checker.nullness.qual.NonNull; - -import java.io.Serializable; - -import static java.util.Objects.requireNonNull; - -record ApiKeyCacheKey(String hash) implements Serializable { - - ApiKeyCacheKey { - requireNonNull(hash); - } - - static ApiKeyCacheKey of(String hash) { - return new ApiKeyCacheKey(hash); - } - - @NonNull - @Override - public String toString() { - return /* cache name */ "_" + hash; - } - -} diff --git a/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyCaffeineCache.java b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyCaffeineCache.java deleted file mode 100644 index 48eab7a32c..0000000000 --- a/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyCaffeineCache.java +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Copyright © 2016-2025 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.dao.pat; - -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.cache.CacheManager; -import org.springframework.stereotype.Service; -import org.thingsboard.server.cache.CaffeineTbTransactionalCache; -import org.thingsboard.server.common.data.CacheConstants; -import org.thingsboard.server.common.data.pat.ApiKey; - -@ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "caffeine", matchIfMissing = true) -@Service("ApiKeyCache") -public class ApiKeyCaffeineCache extends CaffeineTbTransactionalCache { - - public ApiKeyCaffeineCache(CacheManager cacheManager) { - super(cacheManager, CacheConstants.API_KEYS_CACHE); - } - -} diff --git a/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyDao.java b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyDao.java index ae45fbafe5..5d0156a772 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyDao.java @@ -26,9 +26,9 @@ public interface ApiKeyDao extends Dao { ApiKey findByHash(String hash); - Set deleteByTenantId(TenantId tenantId); + void deleteByTenantId(TenantId tenantId); - Set deleteByUserId(TenantId tenantId, UserId userId); + void deleteByUserId(TenantId tenantId, UserId userId); int deleteAllByExpirationTimeBefore(long ts); diff --git a/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyEvictEvent.java b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyEvictEvent.java deleted file mode 100644 index 7f198f3adb..0000000000 --- a/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyEvictEvent.java +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Copyright © 2016-2025 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.dao.pat; - -public record ApiKeyEvictEvent(String hash) { -} diff --git a/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyRedisCache.java b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyRedisCache.java deleted file mode 100644 index eee0b8dc31..0000000000 --- a/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyRedisCache.java +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Copyright © 2016-2025 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.dao.pat; - -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.data.redis.connection.RedisConnectionFactory; -import org.springframework.stereotype.Service; -import org.thingsboard.server.cache.CacheSpecsMap; -import org.thingsboard.server.cache.RedisTbTransactionalCache; -import org.thingsboard.server.cache.TBRedisCacheConfiguration; -import org.thingsboard.server.cache.TbJsonRedisSerializer; -import org.thingsboard.server.common.data.CacheConstants; -import org.thingsboard.server.common.data.pat.ApiKey; - -@ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "redis") -@Service("ApiKeyCache") -public class ApiKeyRedisCache extends RedisTbTransactionalCache { - - public ApiKeyRedisCache(TBRedisCacheConfiguration configuration, CacheSpecsMap cacheSpecsMap, RedisConnectionFactory connectionFactory) { - super(CacheConstants.API_KEYS_CACHE, cacheSpecsMap, connectionFactory, configuration, new TbJsonRedisSerializer<>(ApiKey.class)); - } - -} diff --git a/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyServiceImpl.java index 1b232b3ebd..2c2649cd99 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyServiceImpl.java @@ -18,7 +18,6 @@ package org.thingsboard.server.dao.pat; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; -import org.springframework.transaction.event.TransactionalEventListener; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.id.ApiKeyId; @@ -30,12 +29,10 @@ import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.pat.ApiKey; import org.thingsboard.server.common.data.pat.ApiKeyInfo; -import org.thingsboard.server.dao.entity.AbstractCachedEntityService; -import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; +import org.thingsboard.server.dao.entity.AbstractEntityService; import org.thingsboard.server.dao.service.validator.ApiKeyDataValidator; import java.util.Optional; -import java.util.Set; import java.util.UUID; import static org.thingsboard.server.dao.service.Validator.validateId; @@ -45,7 +42,7 @@ import static org.thingsboard.server.dao.user.UserServiceImpl.INCORRECT_USER_ID; @Slf4j @Service @RequiredArgsConstructor -public class ApiKeyServiceImpl extends AbstractCachedEntityService implements ApiKeyService { +public class ApiKeyServiceImpl extends AbstractEntityService implements ApiKeyService { private static final String INCORRECT_API_KEY_ID = "Incorrect ApiKeyId "; private static final int DEFAULT_API_KEY_BYTES = 32; @@ -54,12 +51,6 @@ public class ApiKeyServiceImpl extends AbstractCachedEntityService INCORRECT_TENANT_ID + id); - Set hashes = apiKeyDao.deleteByTenantId(tenantId); - hashes.forEach(hash -> publishEvictEvent(new ApiKeyEvictEvent(hash))); + apiKeyDao.deleteByTenantId(tenantId); } @Override public void deleteByUserId(TenantId tenantId, UserId userId) { log.trace("Executing deleteApiKeysByUserId, tenantId [{}]", tenantId); validateId(userId, id -> INCORRECT_USER_ID + id); - Set hashes = apiKeyDao.deleteByUserId(tenantId, userId); - hashes.forEach(hash -> publishEvictEvent(new ApiKeyEvictEvent(hash))); + apiKeyDao.deleteByUserId(tenantId, userId); } @Override public ApiKey findApiKeyByHash(String hash) { log.trace("Executing findApiKeyByHash [{}]", hash); - var cacheKey = ApiKeyCacheKey.of(hash); - return cache.getAndPutInTransaction(cacheKey, () -> apiKeyDao.findByHash(hash), true); + return apiKeyDao.findByHash(hash); } private static String generateApiKeySecret() { diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyRepository.java index f175e6f91c..5c299fe739 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyRepository.java @@ -31,23 +31,13 @@ public interface ApiKeyRepository extends JpaRepository { @Transactional @Modifying - @Query(value = """ - DELETE FROM api_key - WHERE tenant_id = :tenantId - RETURNING hash - """, nativeQuery = true - ) - Set deleteByTenantId(@Param("tenantId") UUID tenantId); + @Query("DELETE FROM ApiKeyEntity ak WHERE ak.tenantId = :tenantId") + void deleteByTenantId(@Param("tenantId") UUID tenantId); @Transactional @Modifying - @Query(value = """ - DELETE FROM api_key - WHERE tenant_id = :tenantId AND user_id = :userId - RETURNING hash - """, nativeQuery = true - ) - Set deleteByUserId(@Param("tenantId") UUID tenantId, + @Query("DELETE FROM ApiKeyEntity ak WHERE ak.tenantId = :tenantId AND ak.userId = :userId") + void deleteByUserId(@Param("tenantId") UUID tenantId, @Param("userId") UUID userId); @Transactional diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/pat/JpaApiKeyDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/pat/JpaApiKeyDao.java index 8a3226655d..177f19b528 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/pat/JpaApiKeyDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/pat/JpaApiKeyDao.java @@ -46,13 +46,13 @@ public class JpaApiKeyDao extends JpaAbstractDao implement } @Override - public Set deleteByTenantId(TenantId tenantId) { - return apiKeyRepository.deleteByTenantId(tenantId.getId()); + public void deleteByTenantId(TenantId tenantId) { + apiKeyRepository.deleteByTenantId(tenantId.getId()); } @Override - public Set deleteByUserId(TenantId tenantId, UserId userId) { - return apiKeyRepository.deleteByUserId(tenantId.getId(), userId.getId()); + public void deleteByUserId(TenantId tenantId, UserId userId) { + apiKeyRepository.deleteByUserId(tenantId.getId(), userId.getId()); } @Override From b8790e9e56c124a084c137d936db51f5e2a2d3f1 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Mon, 29 Sep 2025 18:35:18 +0300 Subject: [PATCH 07/56] Cleanup application properties --- .../org/thingsboard/server/dao/sql/pat/ApiKeyRepository.java | 3 +-- dao/src/test/resources/application-test.properties | 3 --- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyRepository.java index 5c299fe739..564ec29ec8 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyRepository.java @@ -22,7 +22,6 @@ import org.springframework.data.repository.query.Param; import org.springframework.transaction.annotation.Transactional; import org.thingsboard.server.dao.model.sql.ApiKeyEntity; -import java.util.Set; import java.util.UUID; public interface ApiKeyRepository extends JpaRepository { @@ -38,7 +37,7 @@ public interface ApiKeyRepository extends JpaRepository { @Modifying @Query("DELETE FROM ApiKeyEntity ak WHERE ak.tenantId = :tenantId AND ak.userId = :userId") void deleteByUserId(@Param("tenantId") UUID tenantId, - @Param("userId") UUID userId); + @Param("userId") UUID userId); @Transactional @Modifying diff --git a/dao/src/test/resources/application-test.properties b/dao/src/test/resources/application-test.properties index de4b342af5..1c2c0c5519 100644 --- a/dao/src/test/resources/application-test.properties +++ b/dao/src/test/resources/application-test.properties @@ -114,9 +114,6 @@ cache.specs.trendzSettings.maxSize=10000 cache.specs.aiModel.timeToLiveInMinutes=1440 cache.specs.aiModel.maxSize=10000 -cache.specs.apiKeys.timeToLiveInMinutes=1440 -cache.specs.apiKeys.maxSize=10000 - redis.connection.host=localhost redis.connection.port=6379 redis.connection.db=0 From 74cd211a4eb4aaaa41170085679f0575ad465784 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Tue, 30 Sep 2025 10:30:19 +0300 Subject: [PATCH 08/56] Fix TenantIdLoaderTest --- .../rule/engine/util/TenantIdLoader.java | 1 + .../rule/engine/util/TenantIdLoaderTest.java | 48 ++----------------- 2 files changed, 6 insertions(+), 43 deletions(-) diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TenantIdLoader.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TenantIdLoader.java index 8ea0f70a3f..6e4b56ccd5 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TenantIdLoader.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TenantIdLoader.java @@ -148,6 +148,7 @@ public class TenantIdLoader { break; case NOTIFICATION: case ADMIN_SETTINGS: + case API_KEY: return ctxTenantId; case NOTIFICATION_RULE: tenantEntity = ctx.getNotificationRuleService().findNotificationRuleById(ctxTenantId, new NotificationRuleId(id)); diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/TenantIdLoaderTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/TenantIdLoaderTest.java index 16698b2841..ec001a9dac 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/TenantIdLoaderTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/TenantIdLoaderTest.java @@ -29,7 +29,6 @@ import org.thingsboard.rule.engine.api.RuleEngineAssetProfileCache; import org.thingsboard.rule.engine.api.RuleEngineDeviceProfileCache; import org.thingsboard.rule.engine.api.RuleEngineRpcService; import org.thingsboard.rule.engine.api.TbContext; -import org.thingsboard.server.common.data.AdminSettings; import org.thingsboard.server.common.data.ApiUsageState; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Dashboard; @@ -53,7 +52,6 @@ import org.thingsboard.server.common.data.id.AssetProfileId; import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityIdFactory; -import org.thingsboard.server.common.data.id.NotificationId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantProfileId; import org.thingsboard.server.common.data.job.Job; @@ -80,6 +78,7 @@ import org.thingsboard.server.dao.device.DeviceService; import org.thingsboard.server.dao.domain.DomainService; import org.thingsboard.server.dao.edge.EdgeService; import org.thingsboard.server.dao.entityview.EntityViewService; +import org.thingsboard.server.dao.job.JobService; import org.thingsboard.server.dao.mobile.MobileAppBundleService; import org.thingsboard.server.dao.mobile.MobileAppService; import org.thingsboard.server.dao.notification.NotificationRequestService; @@ -88,11 +87,11 @@ import org.thingsboard.server.dao.notification.NotificationTargetService; import org.thingsboard.server.dao.notification.NotificationTemplateService; import org.thingsboard.server.dao.oauth2.OAuth2ClientService; import org.thingsboard.server.dao.ota.OtaPackageService; +import org.thingsboard.server.dao.pat.ApiKeyService; import org.thingsboard.server.dao.queue.QueueService; import org.thingsboard.server.dao.queue.QueueStatsService; import org.thingsboard.server.dao.resource.ResourceService; import org.thingsboard.server.dao.rule.RuleChainService; -import org.thingsboard.server.dao.job.JobService; import org.thingsboard.server.dao.user.UserService; import org.thingsboard.server.dao.widget.WidgetTypeService; import org.thingsboard.server.dao.widget.WidgetsBundleService; @@ -170,6 +169,8 @@ public class TenantIdLoaderTest { private JobService jobService; @Mock private AiModelService aiModelService; + @Mock + private ApiKeyService apiKeyService; private TenantId tenantId; private TenantProfileId tenantProfileId; @@ -204,163 +205,124 @@ public class TenantIdLoaderTest { case TENANT: case NOTIFICATION: case ADMIN_SETTINGS: + case API_KEY: break; case CUSTOMER: Customer customer = new Customer(); customer.setTenantId(tenantId); - when(ctx.getCustomerService()).thenReturn(customerService); doReturn(customer).when(customerService).findCustomerById(eq(tenantId), any()); - break; case USER: User user = new User(); user.setTenantId(tenantId); - when(ctx.getUserService()).thenReturn(userService); doReturn(user).when(userService).findUserById(eq(tenantId), any()); - break; case ASSET: Asset asset = new Asset(); asset.setTenantId(tenantId); - when(ctx.getAssetService()).thenReturn(assetService); doReturn(asset).when(assetService).findAssetById(eq(tenantId), any()); - break; case DEVICE: Device device = new Device(); device.setTenantId(tenantId); - when(ctx.getDeviceService()).thenReturn(deviceService); doReturn(device).when(deviceService).findDeviceById(eq(tenantId), any()); - break; case ALARM: Alarm alarm = new Alarm(); alarm.setTenantId(tenantId); - when(ctx.getAlarmService()).thenReturn(alarmService); doReturn(alarm).when(alarmService).findAlarmById(eq(tenantId), any()); - break; case RULE_CHAIN: RuleChain ruleChain = new RuleChain(); ruleChain.setTenantId(tenantId); - when(ctx.getRuleChainService()).thenReturn(ruleChainService); doReturn(ruleChain).when(ruleChainService).findRuleChainById(eq(tenantId), any()); - break; case ENTITY_VIEW: EntityView entityView = new EntityView(); entityView.setTenantId(tenantId); - when(ctx.getEntityViewService()).thenReturn(entityViewService); doReturn(entityView).when(entityViewService).findEntityViewById(eq(tenantId), any()); - break; case DASHBOARD: Dashboard dashboard = new Dashboard(); dashboard.setTenantId(tenantId); - when(ctx.getDashboardService()).thenReturn(dashboardService); doReturn(dashboard).when(dashboardService).findDashboardById(eq(tenantId), any()); - break; case EDGE: Edge edge = new Edge(); edge.setTenantId(tenantId); - when(ctx.getEdgeService()).thenReturn(edgeService); doReturn(edge).when(edgeService).findEdgeById(eq(tenantId), any()); - break; case OTA_PACKAGE: OtaPackage otaPackage = new OtaPackage(); otaPackage.setTenantId(tenantId); - when(ctx.getOtaPackageService()).thenReturn(otaPackageService); doReturn(otaPackage).when(otaPackageService).findOtaPackageInfoById(eq(tenantId), any()); - break; case ASSET_PROFILE: AssetProfile assetProfile = new AssetProfile(); assetProfile.setTenantId(tenantId); - when(ctx.getAssetProfileCache()).thenReturn(assetProfileCache); doReturn(assetProfile).when(assetProfileCache).get(eq(tenantId), any(AssetProfileId.class)); - break; case DEVICE_PROFILE: DeviceProfile deviceProfile = new DeviceProfile(); deviceProfile.setTenantId(tenantId); - when(ctx.getDeviceProfileCache()).thenReturn(deviceProfileCache); doReturn(deviceProfile).when(deviceProfileCache).get(eq(tenantId), any(DeviceProfileId.class)); - break; case WIDGET_TYPE: WidgetType widgetType = new WidgetType(); widgetType.setTenantId(tenantId); - when(ctx.getWidgetTypeService()).thenReturn(widgetTypeService); doReturn(widgetType).when(widgetTypeService).findWidgetTypeById(eq(tenantId), any()); - break; case WIDGETS_BUNDLE: WidgetsBundle widgetsBundle = new WidgetsBundle(); widgetsBundle.setTenantId(tenantId); - when(ctx.getWidgetBundleService()).thenReturn(widgetsBundleService); doReturn(widgetsBundle).when(widgetsBundleService).findWidgetsBundleById(eq(tenantId), any()); - break; case RPC: Rpc rpc = new Rpc(); rpc.setTenantId(tenantId); - when(ctx.getRpcService()).thenReturn(rpcService); doReturn(rpc).when(rpcService).findRpcById(eq(tenantId), any()); - break; case QUEUE: Queue queue = new Queue(); queue.setTenantId(tenantId); - when(ctx.getQueueService()).thenReturn(queueService); doReturn(queue).when(queueService).findQueueById(eq(tenantId), any()); - break; case API_USAGE_STATE: ApiUsageState apiUsageState = new ApiUsageState(); apiUsageState.setTenantId(tenantId); - when(ctx.getRuleEngineApiUsageStateService()).thenReturn(ruleEngineApiUsageStateService); doReturn(apiUsageState).when(ruleEngineApiUsageStateService).findApiUsageStateById(eq(tenantId), any()); - break; case TB_RESOURCE: TbResource tbResource = new TbResource(); tbResource.setTenantId(tenantId); - when(ctx.getResourceService()).thenReturn(resourceService); doReturn(tbResource).when(resourceService).findResourceInfoById(eq(tenantId), any()); - break; case RULE_NODE: RuleNode ruleNode = new RuleNode(); - when(ctx.getRuleChainService()).thenReturn(ruleChainService); doReturn(ruleNode).when(ruleChainService).findRuleNodeById(eq(tenantId), any()); - break; case TENANT_PROFILE: TenantProfile tenantProfile = new TenantProfile(tenantProfileId); - when(ctx.getTenantProfile()).thenReturn(tenantProfile); - break; case NOTIFICATION_TARGET: NotificationTarget notificationTarget = new NotificationTarget(); From 55c42850d7e628726c3459f3fd2efd0f9577f678 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Tue, 30 Sep 2025 10:57:18 +0300 Subject: [PATCH 09/56] Remove unused mock --- .../org/thingsboard/rule/engine/util/TenantIdLoaderTest.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/TenantIdLoaderTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/TenantIdLoaderTest.java index ec001a9dac..f973bad105 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/TenantIdLoaderTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/TenantIdLoaderTest.java @@ -169,8 +169,6 @@ public class TenantIdLoaderTest { private JobService jobService; @Mock private AiModelService aiModelService; - @Mock - private ApiKeyService apiKeyService; private TenantId tenantId; private TenantProfileId tenantProfileId; From c81121c32e589ac3c3b16d04bf6ba0520708bfcf Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Tue, 30 Sep 2025 14:43:15 +0300 Subject: [PATCH 10/56] Fix tests --- .../ThingsboardErrorResponseHandler.java | 25 ++++++++----------- ...wtTokenAuthenticationProcessingFilter.java | 6 ++++- ...RestAwareAuthenticationFailureHandler.java | 7 ++---- 3 files changed, 18 insertions(+), 20 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponseHandler.java b/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponseHandler.java index 7be11e5748..9364121961 100644 --- a/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponseHandler.java +++ b/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponseHandler.java @@ -137,23 +137,22 @@ public class ThingsboardErrorResponseHandler extends ResponseEntityExceptionHand try { response.setContentType(MediaType.APPLICATION_JSON_VALUE); - if (exception instanceof ThingsboardException) { - ThingsboardException thingsboardException = (ThingsboardException) exception; + if (exception instanceof ThingsboardException thingsboardException) { if (thingsboardException.getErrorCode() == ThingsboardErrorCode.SUBSCRIPTION_VIOLATION) { - handleSubscriptionException((ThingsboardException) exception, response); + handleSubscriptionException(thingsboardException, response); } else if (thingsboardException.getErrorCode() == ThingsboardErrorCode.DATABASE) { handleDatabaseException(thingsboardException.getCause(), response); } else { - handleThingsboardException((ThingsboardException) exception, response); + handleThingsboardException(thingsboardException, response); } - } else if (exception instanceof TbRateLimitsException) { - handleRateLimitException(response, (TbRateLimitsException) exception); + } else if (exception instanceof TbRateLimitsException rateLimitsException) { + handleRateLimitException(response, rateLimitsException); } else if (exception instanceof AccessDeniedException) { handleAccessDeniedException(response); - } else if (exception instanceof AuthenticationException) { - handleAuthenticationException((AuthenticationException) exception, response); - } else if (exception instanceof MaxPayloadSizeExceededException) { - handleMaxPayloadSizeExceededException(response, (MaxPayloadSizeExceededException) exception); + } else if (exception instanceof AuthenticationException authenticationException) { + handleAuthenticationException(authenticationException, response); + } else if (exception instanceof MaxPayloadSizeExceededException maxPayloadSizeExceededException) { + handleMaxPayloadSizeExceededException(response, maxPayloadSizeExceededException); } else if (exception instanceof DataAccessException e) { handleDatabaseException(e, response); } else { @@ -238,12 +237,10 @@ public class ThingsboardErrorResponseHandler extends ResponseEntityExceptionHand JacksonUtil.writeValue(response.getWriter(), ThingsboardErrorResponse.of("Token has expired", ThingsboardErrorCode.JWT_TOKEN_EXPIRED, HttpStatus.UNAUTHORIZED)); } else if (authenticationException instanceof AuthMethodNotSupportedException) { JacksonUtil.writeValue(response.getWriter(), ThingsboardErrorResponse.of(authenticationException.getMessage(), ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED)); - } else if (authenticationException instanceof UserPasswordExpiredException) { - UserPasswordExpiredException expiredException = (UserPasswordExpiredException) authenticationException; + } else if (authenticationException instanceof UserPasswordExpiredException expiredException) { String resetToken = expiredException.getResetToken(); JacksonUtil.writeValue(response.getWriter(), ThingsboardCredentialsExpiredResponse.of(expiredException.getMessage(), resetToken)); - } else if (authenticationException instanceof UserPasswordNotValidException) { - UserPasswordNotValidException expiredException = (UserPasswordNotValidException) authenticationException; + } else if (authenticationException instanceof UserPasswordNotValidException expiredException) { JacksonUtil.writeValue(response.getWriter(), ThingsboardCredentialsViolationResponse.of(expiredException.getMessage())); } else { JacksonUtil.writeValue(response.getWriter(), ThingsboardErrorResponse.of("Authentication failed", ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED)); diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/JwtTokenAuthenticationProcessingFilter.java b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/JwtTokenAuthenticationProcessingFilter.java index cd7835b3be..e66741f749 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/JwtTokenAuthenticationProcessingFilter.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/JwtTokenAuthenticationProcessingFilter.java @@ -74,7 +74,11 @@ public class JwtTokenAuthenticationProcessingFilter extends AbstractAuthenticati if (header == null) { header = request.getHeader(JWT_TOKEN_HEADER_PARAM_V2); } - return header != null && header.startsWith(BEARER_HEADER_PREFIX); + if (header == null) { + // If there is NO auth header at all, let the JWT filter try to attempt Authentication and failure in the process. + return true; + } + return header.startsWith(BEARER_HEADER_PREFIX); } @Override diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationFailureHandler.java b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationFailureHandler.java index b37ba1910d..b0b29c8748 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationFailureHandler.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationFailureHandler.java @@ -15,7 +15,6 @@ */ package org.thingsboard.server.service.security.auth.rest; -import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.beans.factory.annotation.Autowired; @@ -24,8 +23,6 @@ import org.springframework.security.web.authentication.AuthenticationFailureHand import org.springframework.stereotype.Component; import org.thingsboard.server.exception.ThingsboardErrorResponseHandler; -import java.io.IOException; - @Component(value = "defaultAuthenticationFailureHandler") public class RestAwareAuthenticationFailureHandler implements AuthenticationFailureHandler { @@ -37,8 +34,8 @@ public class RestAwareAuthenticationFailureHandler implements AuthenticationFail } @Override - public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, - AuthenticationException e) throws IOException, ServletException { + public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) { errorResponseHandler.handle(e, response); } + } From 5aae9aebdd28e36a3cb89f39a3c76170f0b57d89 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Thu, 2 Oct 2025 14:43:08 +0300 Subject: [PATCH 11/56] Minor changes --- .../security/auth/pat/ApiKeyAuthenticationProviderTest.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProviderTest.java b/application/src/test/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProviderTest.java index 367e47dc85..cbea0f71e5 100644 --- a/application/src/test/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProviderTest.java +++ b/application/src/test/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProviderTest.java @@ -21,6 +21,7 @@ import org.junit.runner.RunWith; import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.CredentialsExpiredException; import org.springframework.security.authentication.DisabledException; import org.springframework.security.authentication.InsufficientAuthenticationException; import org.springframework.security.core.Authentication; @@ -114,7 +115,7 @@ public class ApiKeyAuthenticationProviderTest { provider.authenticate(token); } - @Test(expected = UsernameNotFoundException.class) + @Test(expected = BadCredentialsException.class) public void testNonExistentApiKey() { when(apiKeyService.findApiKeyByHash(TEST_API_KEY)).thenReturn(null); ApiKeyAuthenticationToken token = new ApiKeyAuthenticationToken(new RawApiKeyToken(TEST_API_KEY)); @@ -131,7 +132,7 @@ public class ApiKeyAuthenticationProviderTest { provider.authenticate(token); } - @Test(expected = BadCredentialsException.class) + @Test(expected = CredentialsExpiredException.class) public void testExpiredApiKey() { apiKey.setExpirationTime(System.currentTimeMillis() - 10000); // Expired 10 seconds ago when(apiKeyService.findApiKeyByHash(TEST_API_KEY)).thenReturn(apiKey); From 11199d347b258500f46215e46d52146e6a014222 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Tue, 14 Oct 2025 12:05:12 +0300 Subject: [PATCH 12/56] Small refactoring --- .../server/controller/ApiKeyController.java | 2 +- .../auth/pat/ApiKeyAuthenticationProvider.java | 2 +- .../controller/ApiKeyControllerTest.java | 2 +- .../pat/ApiKeyAuthenticationProviderTest.java | 18 +++++++++--------- .../server/dao/pat/ApiKeyService.java | 2 +- .../server/common/data/pat/ApiKey.java | 12 ++++++------ .../server/dao/model/ModelConstants.java | 2 +- .../server/dao/model/sql/ApiKeyEntity.java | 10 +++++----- .../thingsboard/server/dao/pat/ApiKeyDao.java | 4 +--- .../server/dao/pat/ApiKeyServiceImpl.java | 10 +++++----- .../server/dao/sql/pat/ApiKeyRepository.java | 2 +- .../server/dao/sql/pat/JpaApiKeyDao.java | 4 ++-- .../server/dao/tenant/TenantServiceImpl.java | 2 +- .../main/resources/sql/schema-entities-idx.sql | 2 +- dao/src/main/resources/sql/schema-entities.sql | 2 +- .../server/dao/service/ApiKeyServiceTest.java | 12 ++++++------ 16 files changed, 43 insertions(+), 45 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java b/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java index deb0628988..e93a4cc14a 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java +++ b/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java @@ -74,7 +74,7 @@ public class ApiKeyController extends BaseController { apiKeyInfo.setTenantId(securityUser.getTenantId()); checkEntity(apiKeyInfo.getId(), apiKeyInfo, Resource.API_KEY); ApiKey savedApiKey = checkNotNull(apiKeyService.saveApiKey(securityUser.getTenantId(), apiKeyInfo)); - savedApiKey.setHash(toUserApiKey(savedApiKey.getHash())); + savedApiKey.setValue(toUserApiKey(savedApiKey.getValue())); return savedApiKey; } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProvider.java index e0702d7440..68a676ef18 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProvider.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProvider.java @@ -59,7 +59,7 @@ public class ApiKeyAuthenticationProvider implements org.springframework.securit if (StringUtils.isEmpty(key)) { throw new BadCredentialsException("Empty API key"); } - ApiKey apiKey = apiKeyService.findApiKeyByHash(key); + ApiKey apiKey = apiKeyService.findApiKeyByValue(key); if (apiKey == null) { throw new BadCredentialsException("User not found for the provided API key"); } diff --git a/application/src/test/java/org/thingsboard/server/controller/ApiKeyControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/ApiKeyControllerTest.java index 8b9e7cae8c..d71d73b6df 100644 --- a/application/src/test/java/org/thingsboard/server/controller/ApiKeyControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/ApiKeyControllerTest.java @@ -42,7 +42,7 @@ public class ApiKeyControllerTest extends AbstractControllerTest { public void testSaveApiKey() throws Exception { ApiKeyInfo apiKeyInfo = constructApiKeyInfo("New API key description", true); - String apiKeyStr = doPost("/api/apiKey", apiKeyInfo, ApiKey.class).getHash(); + String apiKeyStr = doPost("/api/apiKey", apiKeyInfo, ApiKey.class).getValue(); Assert.assertTrue(apiKeyStr.startsWith(API_KEY_HEADER_PREFIX)); PageData pageData = doGetTypedWithPageLink("/api/apiKeys/" + tenantAdminUserId + "?", new TypeReference<>() {}, new PageLink(10, 0)); diff --git a/application/src/test/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProviderTest.java b/application/src/test/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProviderTest.java index cbea0f71e5..00fac8866a 100644 --- a/application/src/test/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProviderTest.java +++ b/application/src/test/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProviderTest.java @@ -83,14 +83,14 @@ public class ApiKeyAuthenticationProviderTest { apiKey.setId(new ApiKeyId(UUID.randomUUID())); apiKey.setTenantId(tenantId); apiKey.setUserId(userId); - apiKey.setHash(TEST_API_KEY); + apiKey.setValue(TEST_API_KEY); apiKey.setEnabled(true); apiKey.setExpirationTime(0); } @Test public void testSuccessfulAuthentication() { - when(apiKeyService.findApiKeyByHash(TEST_API_KEY)).thenReturn(apiKey); + when(apiKeyService.findApiKeyByValue(TEST_API_KEY)).thenReturn(apiKey); when(userService.findUserById(tenantId, userId)).thenReturn(user); when(userService.findUserCredentialsByUserId(tenantId, userId)).thenReturn(userCredentials); @@ -117,7 +117,7 @@ public class ApiKeyAuthenticationProviderTest { @Test(expected = BadCredentialsException.class) public void testNonExistentApiKey() { - when(apiKeyService.findApiKeyByHash(TEST_API_KEY)).thenReturn(null); + when(apiKeyService.findApiKeyByValue(TEST_API_KEY)).thenReturn(null); ApiKeyAuthenticationToken token = new ApiKeyAuthenticationToken(new RawApiKeyToken(TEST_API_KEY)); provider.authenticate(token); @@ -126,7 +126,7 @@ public class ApiKeyAuthenticationProviderTest { @Test(expected = DisabledException.class) public void testDisabledApiKey() { apiKey.setEnabled(false); - when(apiKeyService.findApiKeyByHash(TEST_API_KEY)).thenReturn(apiKey); + when(apiKeyService.findApiKeyByValue(TEST_API_KEY)).thenReturn(apiKey); ApiKeyAuthenticationToken token = new ApiKeyAuthenticationToken(new RawApiKeyToken(TEST_API_KEY)); provider.authenticate(token); @@ -135,7 +135,7 @@ public class ApiKeyAuthenticationProviderTest { @Test(expected = CredentialsExpiredException.class) public void testExpiredApiKey() { apiKey.setExpirationTime(System.currentTimeMillis() - 10000); // Expired 10 seconds ago - when(apiKeyService.findApiKeyByHash(TEST_API_KEY)).thenReturn(apiKey); + when(apiKeyService.findApiKeyByValue(TEST_API_KEY)).thenReturn(apiKey); ApiKeyAuthenticationToken token = new ApiKeyAuthenticationToken(new RawApiKeyToken(TEST_API_KEY)); provider.authenticate(token); @@ -143,7 +143,7 @@ public class ApiKeyAuthenticationProviderTest { @Test(expected = UsernameNotFoundException.class) public void testNonExistentUser() { - when(apiKeyService.findApiKeyByHash(TEST_API_KEY)).thenReturn(apiKey); + when(apiKeyService.findApiKeyByValue(TEST_API_KEY)).thenReturn(apiKey); when(userService.findUserById(tenantId, userId)).thenReturn(null); ApiKeyAuthenticationToken token = new ApiKeyAuthenticationToken(new RawApiKeyToken(TEST_API_KEY)); @@ -152,7 +152,7 @@ public class ApiKeyAuthenticationProviderTest { @Test(expected = UsernameNotFoundException.class) public void testNonExistentUserCredentials() { - when(apiKeyService.findApiKeyByHash(TEST_API_KEY)).thenReturn(apiKey); + when(apiKeyService.findApiKeyByValue(TEST_API_KEY)).thenReturn(apiKey); when(userService.findUserById(tenantId, userId)).thenReturn(user); when(userService.findUserCredentialsByUserId(tenantId, userId)).thenReturn(null); ApiKeyAuthenticationToken token = new ApiKeyAuthenticationToken(new RawApiKeyToken(TEST_API_KEY)); @@ -163,7 +163,7 @@ public class ApiKeyAuthenticationProviderTest { @Test(expected = DisabledException.class) public void testDisabledUser() { userCredentials.setEnabled(false); - when(apiKeyService.findApiKeyByHash(TEST_API_KEY)).thenReturn(apiKey); + when(apiKeyService.findApiKeyByValue(TEST_API_KEY)).thenReturn(apiKey); when(userService.findUserById(tenantId, userId)).thenReturn(user); when(userService.findUserCredentialsByUserId(tenantId, userId)).thenReturn(userCredentials); ApiKeyAuthenticationToken token = new ApiKeyAuthenticationToken(new RawApiKeyToken(TEST_API_KEY)); @@ -174,7 +174,7 @@ public class ApiKeyAuthenticationProviderTest { @Test(expected = InsufficientAuthenticationException.class) public void testUserWithoutAuthority() { user.setAuthority(null); - when(apiKeyService.findApiKeyByHash(TEST_API_KEY)).thenReturn(apiKey); + when(apiKeyService.findApiKeyByValue(TEST_API_KEY)).thenReturn(apiKey); when(userService.findUserById(tenantId, userId)).thenReturn(user); when(userService.findUserCredentialsByUserId(tenantId, userId)).thenReturn(userCredentials); ApiKeyAuthenticationToken token = new ApiKeyAuthenticationToken(new RawApiKeyToken(TEST_API_KEY)); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/pat/ApiKeyService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/pat/ApiKeyService.java index 375dce2cd8..2fb67d2052 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/pat/ApiKeyService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/pat/ApiKeyService.java @@ -32,7 +32,7 @@ public interface ApiKeyService extends EntityDaoService { void deleteByUserId(TenantId tenantId, UserId userId); - ApiKey findApiKeyByHash(String hash); + ApiKey findApiKeyByValue(String value); ApiKey findApiKeyById(TenantId tenantId, ApiKeyId apiKeyId); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/pat/ApiKey.java b/common/data/src/main/java/org/thingsboard/server/common/data/pat/ApiKey.java index 82d88f1ae7..81753322ba 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/pat/ApiKey.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/pat/ApiKey.java @@ -32,8 +32,8 @@ public class ApiKey extends ApiKeyInfo { private static final long serialVersionUID = -2313196723950490263L; @NoXss - @Schema(description = "Api key hash value", requiredMode = Schema.RequiredMode.REQUIRED) - private String hash; + @Schema(description = "Api key value", requiredMode = Schema.RequiredMode.REQUIRED) + private String value; public ApiKey() { super(); @@ -45,17 +45,17 @@ public class ApiKey extends ApiKeyInfo { public ApiKey(ApiKey apiKey) { super(apiKey); - this.hash = apiKey.getHash(); + this.value = apiKey.getValue(); } public ApiKey(ApiKeyInfo apiKeyInfo) { super(apiKeyInfo); - this.hash = null; + this.value = null; } - public ApiKey(ApiKeyInfo apiKeyInfo, String hash) { + public ApiKey(ApiKeyInfo apiKeyInfo, String value) { super(apiKeyInfo); - this.hash = hash; + this.value = value; } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java index 6cc51e93c1..a5534b743c 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java @@ -766,7 +766,7 @@ public class ModelConstants { public static final String API_KEY_TABLE_NAME = "api_key"; public static final String API_KEY_TENANT_ID_COLUMN_NAME = TENANT_ID_COLUMN; public static final String API_KEY_USER_ID_COLUMN_NAME = USER_ID_PROPERTY; - public static final String API_KEY_HASH_COLUMN_NAME = "hash"; + public static final String API_KEY_VALUE_COLUMN_NAME = "value"; public static final String API_KEY_EXPIRATION_TIME_COLUMN_NAME = "expiration_time"; public static final String API_KEY_ENABLED_COLUMN_NAME = "enabled"; public static final String API_KEY_DESCRIPTION_COLUMN_NAME = "description"; diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/ApiKeyEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/ApiKeyEntity.java index 30126ac699..0942b0b5af 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/ApiKeyEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/ApiKeyEntity.java @@ -22,8 +22,8 @@ import lombok.Data; import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.pat.ApiKey; -import static org.thingsboard.server.dao.model.ModelConstants.API_KEY_HASH_COLUMN_NAME; import static org.thingsboard.server.dao.model.ModelConstants.API_KEY_TABLE_NAME; +import static org.thingsboard.server.dao.model.ModelConstants.API_KEY_VALUE_COLUMN_NAME; @Data @EqualsAndHashCode(callSuper = true) @@ -31,8 +31,8 @@ import static org.thingsboard.server.dao.model.ModelConstants.API_KEY_TABLE_NAME @Table(name = API_KEY_TABLE_NAME) public class ApiKeyEntity extends AbstractApiKeyInfoEntity { - @Column(name = API_KEY_HASH_COLUMN_NAME) - private String hash; + @Column(name = API_KEY_VALUE_COLUMN_NAME) + private String value; public ApiKeyEntity() { super(); @@ -40,12 +40,12 @@ public class ApiKeyEntity extends AbstractApiKeyInfoEntity { public ApiKeyEntity(ApiKey apiKey) { super(apiKey); - this.hash = apiKey.getHash(); + this.value = apiKey.getValue(); } @Override public ApiKey toData() { - return new ApiKey(super.toApiKeyInfo(), hash); + return new ApiKey(super.toApiKeyInfo(), value); } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyDao.java b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyDao.java index 5d0156a772..73c57b2840 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyDao.java @@ -20,11 +20,9 @@ import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.pat.ApiKey; import org.thingsboard.server.dao.Dao; -import java.util.Set; - public interface ApiKeyDao extends Dao { - ApiKey findByHash(String hash); + ApiKey findByValue(String value); void deleteByTenantId(TenantId tenantId); diff --git a/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyServiceImpl.java index 2c2649cd99..cf4d4209af 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyServiceImpl.java @@ -59,9 +59,9 @@ public class ApiKeyServiceImpl extends AbstractEntityService implements ApiKeySe var old = apiKeyValidator.validate(apiKey, ApiKeyInfo::getTenantId); if (old == null) { String hash = generateApiKeySecret(); - apiKey.setHash(hash); + apiKey.setValue(hash); } else { - apiKey.setHash(old.getHash()); + apiKey.setValue(old.getValue()); } return apiKeyDao.save(tenantId, apiKey); } catch (Exception e) { @@ -124,9 +124,9 @@ public class ApiKeyServiceImpl extends AbstractEntityService implements ApiKeySe } @Override - public ApiKey findApiKeyByHash(String hash) { - log.trace("Executing findApiKeyByHash [{}]", hash); - return apiKeyDao.findByHash(hash); + public ApiKey findApiKeyByValue(String value) { + log.trace("Executing findApiKeyByValue [{}]", value); + return apiKeyDao.findByValue(value); } private static String generateApiKeySecret() { diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyRepository.java index 564ec29ec8..2c797db7c1 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyRepository.java @@ -26,7 +26,7 @@ import java.util.UUID; public interface ApiKeyRepository extends JpaRepository { - ApiKeyEntity findByHash(String hash); + ApiKeyEntity findByValue(String value); @Transactional @Modifying diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/pat/JpaApiKeyDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/pat/JpaApiKeyDao.java index 177f19b528..f539170798 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/pat/JpaApiKeyDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/pat/JpaApiKeyDao.java @@ -41,8 +41,8 @@ public class JpaApiKeyDao extends JpaAbstractDao implement private ApiKeyRepository apiKeyRepository; @Override - public ApiKey findByHash(String hash) { - return DaoUtil.getData(apiKeyRepository.findByHash(hash)); + public ApiKey findByValue(String value) { + return DaoUtil.getData(apiKeyRepository.findByValue(value)); } @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java index b7ba7126b6..0df7c36527 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java @@ -179,7 +179,7 @@ public class TenantServiceImpl extends AbstractCachedEntityService Date: Tue, 14 Oct 2025 12:08:21 +0300 Subject: [PATCH 13/56] Rename constaint for api-key table --- dao/src/main/resources/sql/schema-entities.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dao/src/main/resources/sql/schema-entities.sql b/dao/src/main/resources/sql/schema-entities.sql index 0d0babc4e9..9d811250ac 100644 --- a/dao/src/main/resources/sql/schema-entities.sql +++ b/dao/src/main/resources/sql/schema-entities.sql @@ -718,7 +718,7 @@ CREATE TABLE IF NOT EXISTS api_key ( enabled boolean NOT NULL DEFAULT TRUE, expiration_time bigint DEFAULT 0, description varchar(1024), - CONSTRAINT api_hash_unq_key UNIQUE (hash) + CONSTRAINT api_value_unq_key UNIQUE (value) ); CREATE TABLE IF NOT EXISTS resource ( From 291f5579173177413f242e98acb88adb0ef62e27 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Tue, 14 Oct 2025 12:45:13 +0300 Subject: [PATCH 14/56] Fix typo in ApiKeyController --- .../org/thingsboard/server/controller/ApiKeyController.java | 4 ++-- dao/src/main/resources/sql/schema-entities-idx.sql | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java b/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java index e93a4cc14a..26987ac07b 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java +++ b/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java @@ -139,8 +139,8 @@ public class ApiKeyController extends BaseController { apiKeyService.deleteApiKey(apiKey.getTenantId(), apiKey, false); } - private String toUserApiKey(String hash) { - return API_KEY_HEADER_PREFIX + hash; + private String toUserApiKey(String value) { + return API_KEY_HEADER_PREFIX + value; } } diff --git a/dao/src/main/resources/sql/schema-entities-idx.sql b/dao/src/main/resources/sql/schema-entities-idx.sql index b3c947649f..36b231272e 100644 --- a/dao/src/main/resources/sql/schema-entities-idx.sql +++ b/dao/src/main/resources/sql/schema-entities-idx.sql @@ -116,4 +116,4 @@ CREATE INDEX IF NOT EXISTS idx_job_tenant_id ON job(tenant_id); CREATE INDEX IF NOT EXISTS idx_ai_model_tenant_id ON ai_model(tenant_id); -CREATE INDEX IF NOT EXISTS idx_api_key_value ON api_key(value); +CREATE INDEX IF NOT EXISTS idx_api_key_user_id ON api_key(user_id); From 1f884bb5ca144279722b53a761b4ad9ea574f5c6 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Wed, 15 Oct 2025 12:23:03 +0300 Subject: [PATCH 15/56] UserAuthDetailsCache. Refactoring after review --- .../ThingsboardSecurityConfiguration.java | 20 +- .../server/controller/ApiKeyController.java | 31 +-- .../entitiy/EntityStateSourcingListener.java | 8 +- .../queue/DefaultTbClusterService.java | 3 +- .../AbstractHeaderTokenExtractor.java | 4 +- ...wtTokenAuthenticationProcessingFilter.java | 8 +- .../RefreshTokenAuthenticationProvider.java | 32 ++- .../pat/ApiKeyAuthenticationProvider.java | 34 ++- .../auth/pat/ApiKeyAuthenticationToken.java | 12 +- ...eyTokenAuthenticationProcessingFilter.java | 14 +- .../auth/rest/RestAuthenticationProvider.java | 3 +- .../{RawApiKeyToken.java => RawApiKey.java} | 4 +- .../service/ttl/ApiKeysCleanUpService.java | 8 +- .../cache/DefaultUserAuthDetailsCache.java | 85 +++++++ .../user/cache/UserAuthDetailsCache.java | 26 +++ .../src/main/resources/thingsboard.yml | 13 +- .../server/controller/AbstractWebTest.java | 55 ++++- .../controller/ApiKeyControllerTest.java | 4 +- .../security/auth/TokenOutdatingTest.java | 14 +- .../pat/ApiKeyAuthenticationProviderTest.java | 208 ++++++------------ .../server/dao/user/UserService.java | 3 + .../server/common/data/CacheConstants.java | 1 + .../server/common/data/UserAuthDetails.java | 18 ++ .../msg/plugin/ComponentLifecycleMsg.java | 3 - .../server/dao/model/sql/UserEntity.java | 3 - .../server/dao/pat/ApiKeyCacheKey.java | 40 ++++ .../server/dao/pat/ApiKeyCaffeineCache.java | 33 +++ .../thingsboard/server/dao/pat/ApiKeyDao.java | 6 +- .../server/dao/pat/ApiKeyEvictEvent.java | 18 ++ .../server/dao/pat/ApiKeyRedisCache.java | 36 +++ .../server/dao/pat/ApiKeyServiceImpl.java | 66 +++--- .../validator/ApiKeyDataValidator.java | 18 +- .../server/dao/sql/pat/ApiKeyRepository.java | 21 +- .../server/dao/sql/pat/JpaApiKeyDao.java | 8 +- .../server/dao/sql/user/JpaUserDao.java | 8 + .../server/dao/sql/user/UserRepository.java | 9 +- .../server/dao/tenant/TenantServiceImpl.java | 2 +- .../thingsboard/server/dao/user/UserDao.java | 3 + .../server/dao/user/UserServiceImpl.java | 10 +- .../main/resources/sql/schema-entities.sql | 4 +- .../thingsboard/rest/client/RestClient.java | 10 +- 41 files changed, 587 insertions(+), 319 deletions(-) rename application/src/main/java/org/thingsboard/server/service/security/model/token/{RawApiKeyToken.java => RawApiKey.java} (93%) create mode 100644 application/src/main/java/org/thingsboard/server/service/user/cache/DefaultUserAuthDetailsCache.java create mode 100644 application/src/main/java/org/thingsboard/server/service/user/cache/UserAuthDetailsCache.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/UserAuthDetails.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyCacheKey.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyCaffeineCache.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyEvictEvent.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyRedisCache.java diff --git a/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java b/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java index d4e6bf2f8f..b467f01f35 100644 --- a/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java +++ b/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java @@ -61,9 +61,9 @@ import org.thingsboard.server.service.security.auth.rest.RestLoginProcessingFilt import org.thingsboard.server.service.security.auth.rest.RestPublicLoginProcessingFilter; import org.thingsboard.server.transport.http.config.PayloadSizeFilter; -import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.stream.Stream; @Configuration @EnableWebSecurity @@ -72,8 +72,8 @@ import java.util.List; @TbCoreComponent public class ThingsboardSecurityConfiguration { - public static final String JWT_TOKEN_HEADER_PARAM = "X-Authorization"; - public static final String JWT_TOKEN_HEADER_PARAM_V2 = "Authorization"; + public static final String AUTHORIZATION_HEADER = "X-Authorization"; + public static final String AUTHORIZATION_HEADER_V2 = "Authorization"; public static final String JWT_TOKEN_QUERY_PARAM = "token"; public static final String API_KEY_HEADER_PREFIX = "ApiKey "; @@ -192,10 +192,16 @@ public class ThingsboardSecurityConfiguration { } private SkipPathRequestMatcher buildSkipPathRequestMatcher() { - List pathsToSkip = new ArrayList<>(Arrays.asList(NON_TOKEN_BASED_AUTH_ENTRY_POINTS)); - pathsToSkip.addAll(Arrays.asList(WS_ENTRY_POINT, TOKEN_REFRESH_ENTRY_POINT, FORM_BASED_LOGIN_ENTRY_POINT, - PUBLIC_LOGIN_ENTRY_POINT, DEVICE_API_ENTRY_POINT, MAIL_OAUTH2_PROCESSING_ENTRY_POINT, - DEVICE_CONNECTIVITY_CERTIFICATE_DOWNLOAD_ENTRY_POINT)); + List pathsToSkip = Stream.concat( + Arrays.stream(NON_TOKEN_BASED_AUTH_ENTRY_POINTS), + Stream.of( + WS_ENTRY_POINT, + TOKEN_REFRESH_ENTRY_POINT, + FORM_BASED_LOGIN_ENTRY_POINT, + PUBLIC_LOGIN_ENTRY_POINT, + DEVICE_API_ENTRY_POINT, + MAIL_OAUTH2_PROCESSING_ENTRY_POINT, + DEVICE_CONNECTIVITY_CERTIFICATE_DOWNLOAD_ENTRY_POINT)).toList(); return new SkipPathRequestMatcher(pathsToSkip, TOKEN_BASED_AUTH_ENTRY_POINT); } diff --git a/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java b/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java index 26987ac07b..41d6b33f21 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java +++ b/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java @@ -46,12 +46,11 @@ import org.thingsboard.server.service.security.permission.Resource; import java.util.Optional; import java.util.UUID; -import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.API_KEY_HEADER_PREFIX; import static org.thingsboard.server.controller.ControllerConstants.API_KEY_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER; import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; -import static org.thingsboard.server.controller.ControllerConstants.SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH; import static org.thingsboard.server.controller.ControllerConstants.USER_ID_PARAM_DESCRIPTION; @RestController @@ -64,8 +63,8 @@ public class ApiKeyController extends BaseController { private final ApiKeyService apiKeyService; @ApiOperation(value = "Save API key for user (saveApiKey)", - notes = "Creates an API key for the given user and returns the token ONCE as 'ApiKey '." + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) - @PreAuthorize("hasAnyAuthority('SYS_ADMIN','TENANT_ADMIN')") + notes = "Creates an API key for the given user and returns the token ONCE as 'ApiKey '." + AVAILABLE_FOR_ANY_AUTHORIZED_USER) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN','TENANT_ADMIN', 'CUSTOMER_USER')") @PostMapping(value = "/apiKey") public ApiKey saveApiKey( @Parameter(description = "A JSON value representing the Api Key token.") @@ -73,15 +72,13 @@ public class ApiKeyController extends BaseController { SecurityUser securityUser = getCurrentUser(); apiKeyInfo.setTenantId(securityUser.getTenantId()); checkEntity(apiKeyInfo.getId(), apiKeyInfo, Resource.API_KEY); - ApiKey savedApiKey = checkNotNull(apiKeyService.saveApiKey(securityUser.getTenantId(), apiKeyInfo)); - savedApiKey.setValue(toUserApiKey(savedApiKey.getValue())); - return savedApiKey; + return checkNotNull(apiKeyService.saveApiKey(securityUser.getTenantId(), apiKeyInfo)); } @ApiOperation(value = "Get User Api Keys (getUserApiKeys)", notes = "Returns a page of api keys owned by user. " + - PAGE_DATA_PARAMETERS + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) - @PreAuthorize("hasAnyAuthority('SYS_ADMIN','TENANT_ADMIN')") + PAGE_DATA_PARAMETERS + AVAILABLE_FOR_ANY_AUTHORIZED_USER) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN','TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/apiKeys/{userId}") public PageData getUserApiKeys( @Parameter(description = USER_ID_PARAM_DESCRIPTION) @@ -100,8 +97,8 @@ public class ApiKeyController extends BaseController { @ApiOperation(value = "Update API key Description", notes = "Updates the description of the existing API key by apiKeyId. " + "Only the description can be updated. " + - "Referencing a non-existing ApiKey Id will cause a 'Not Found' error." + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) - @PreAuthorize("hasAnyAuthority('SYS_ADMIN','TENANT_ADMIN')") + "Referencing a non-existing ApiKey Id will cause a 'Not Found' error." + AVAILABLE_FOR_ANY_AUTHORIZED_USER) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN','TENANT_ADMIN', 'CUSTOMER_USER')") @PutMapping("/apiKey/{id}/description") public ApiKeyInfo updateApiKeyDescription( @Parameter(description = API_KEY_ID_PARAM_DESCRIPTION, required = true) @@ -115,8 +112,8 @@ public class ApiKeyController extends BaseController { } @ApiOperation(value = "Enable or disable API key (enableApiKey)", - notes = "Updates api key with enabled = true/false. " + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) - @PreAuthorize("hasAnyAuthority('SYS_ADMIN','TENANT_ADMIN')") + notes = "Updates api key with enabled = true/false. " + AVAILABLE_FOR_ANY_AUTHORIZED_USER) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN','TENANT_ADMIN', 'CUSTOMER_USER')") @PutMapping(value = "/apiKey/{id}/enabled/{enabledValue}") public ApiKeyInfo enableApiKey( @Parameter(description = "Unique identifier of the API key to enable/disable", required = true) @@ -130,8 +127,8 @@ public class ApiKeyController extends BaseController { } @ApiOperation(value = "Delete API key by ID (deleteApiKey)", - notes = "Deletes the API key. Referencing non-existing ApiKey Id will cause an error." + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) - @PreAuthorize("hasAnyAuthority('SYS_ADMIN','TENANT_ADMIN')") + notes = "Deletes the API key. Referencing non-existing ApiKey Id will cause an error." + AVAILABLE_FOR_ANY_AUTHORIZED_USER) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN','TENANT_ADMIN', 'CUSTOMER_USER')") @DeleteMapping(value = "/apiKey/{id}") public void deleteApiKey(@PathVariable UUID id) throws ThingsboardException { ApiKeyId apiKeyId = new ApiKeyId(id); @@ -139,8 +136,4 @@ public class ApiKeyController extends BaseController { apiKeyService.deleteApiKey(apiKey.getTenantId(), apiKey, false); } - private String toUserApiKey(String value) { - return API_KEY_HEADER_PREFIX + value; - } - } diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/EntityStateSourcingListener.java b/application/src/main/java/org/thingsboard/server/service/entitiy/EntityStateSourcingListener.java index 03ab77ac09..07e96dd897 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/EntityStateSourcingListener.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/EntityStateSourcingListener.java @@ -31,6 +31,7 @@ import org.thingsboard.server.common.data.TbResource; import org.thingsboard.server.common.data.TbResourceInfo; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.cf.CalculatedField; @@ -54,6 +55,7 @@ import org.thingsboard.server.common.msg.edge.EdgeEventUpdateMsg; import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; import org.thingsboard.server.common.msg.rule.engine.DeviceCredentialsUpdateNotificationMsg; import org.thingsboard.server.dao.edge.EdgeSynchronizationManager; +import org.thingsboard.server.dao.eventsourcing.ActionCause; import org.thingsboard.server.dao.eventsourcing.ActionEntityEvent; import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent; import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; @@ -99,7 +101,7 @@ public class EntityStateSourcingListener { case ASSET -> { onAssetUpdate(event.getEntity(), event.getOldEntity()); } - case ASSET_PROFILE, ENTITY_VIEW, NOTIFICATION_RULE -> { + case ASSET_PROFILE, ENTITY_VIEW, NOTIFICATION_RULE, USER -> { tbClusterService.broadcastEntityStateChangeEvent(tenantId, entityId, lifecycleEvent); } case RULE_CHAIN -> { @@ -164,7 +166,7 @@ public class EntityStateSourcingListener { Asset asset = (Asset) event.getEntity(); tbClusterService.onAssetDeleted(tenantId, asset, null); } - case ASSET_PROFILE, ENTITY_VIEW, CUSTOMER, EDGE, NOTIFICATION_RULE -> { + case ASSET_PROFILE, ENTITY_VIEW, CUSTOMER, EDGE, NOTIFICATION_RULE, USER -> { tbClusterService.broadcastEntityStateChangeEvent(tenantId, entityId, ComponentLifecycleEvent.DELETED); } case NOTIFICATION_REQUEST -> { @@ -228,6 +230,8 @@ public class EntityStateSourcingListener { tbClusterService.onDeviceAssignedToTenant(tenant.getId(), device); } pushAssignedFromNotification(tenant, event.getTenantId(), device); + } else if (event.getActionType() == ActionType.CREDENTIALS_UPDATED && event.getEntityId() != null && event.getEntityId().getEntityType() == EntityType.USER) { + tbClusterService.broadcastEntityStateChangeEvent(event.getTenantId(), event.getEntityId(), ComponentLifecycleEvent.UPDATED); } } diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java index 265f14c4e2..2d5f43420f 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java @@ -592,7 +592,8 @@ public class DefaultTbClusterService implements TbClusterService { EntityType.TENANT_PROFILE, EntityType.DEVICE_PROFILE, EntityType.ASSET_PROFILE, - EntityType.JOB) + EntityType.JOB, + EntityType.USER) || (entityType == EntityType.ASSET && msg.getEvent() == ComponentLifecycleEvent.UPDATED) || (entityType == EntityType.DEVICE && msg.getEvent() == ComponentLifecycleEvent.UPDATED) ) { diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/extractor/AbstractHeaderTokenExtractor.java b/application/src/main/java/org/thingsboard/server/service/security/auth/extractor/AbstractHeaderTokenExtractor.java index d8bd0834e1..cafb7619a7 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/extractor/AbstractHeaderTokenExtractor.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/extractor/AbstractHeaderTokenExtractor.java @@ -30,9 +30,9 @@ public abstract class AbstractHeaderTokenExtractor implements TokenExtractor { @Override public String extract(HttpServletRequest request) { - String header = request.getHeader(ThingsboardSecurityConfiguration.JWT_TOKEN_HEADER_PARAM); + String header = request.getHeader(ThingsboardSecurityConfiguration.AUTHORIZATION_HEADER); if (StringUtils.isBlank(header)) { - header = request.getHeader(ThingsboardSecurityConfiguration.JWT_TOKEN_HEADER_PARAM_V2); + header = request.getHeader(ThingsboardSecurityConfiguration.AUTHORIZATION_HEADER_V2); if (StringUtils.isBlank(header)) { throw new AuthenticationServiceException("Authorization header cannot be blank!"); } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/JwtTokenAuthenticationProcessingFilter.java b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/JwtTokenAuthenticationProcessingFilter.java index e66741f749..3c00f09ab7 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/JwtTokenAuthenticationProcessingFilter.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/JwtTokenAuthenticationProcessingFilter.java @@ -33,9 +33,9 @@ import org.thingsboard.server.service.security.model.token.RawAccessJwtToken; import java.io.IOException; +import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.AUTHORIZATION_HEADER; +import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.AUTHORIZATION_HEADER_V2; import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.BEARER_HEADER_PREFIX; -import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.JWT_TOKEN_HEADER_PARAM; -import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.JWT_TOKEN_HEADER_PARAM_V2; public class JwtTokenAuthenticationProcessingFilter extends AbstractAuthenticationProcessingFilter { @@ -70,9 +70,9 @@ public class JwtTokenAuthenticationProcessingFilter extends AbstractAuthenticati if (!super.requiresAuthentication(request, response)) { return false; } - String header = request.getHeader(JWT_TOKEN_HEADER_PARAM); + String header = request.getHeader(AUTHORIZATION_HEADER); if (header == null) { - header = request.getHeader(JWT_TOKEN_HEADER_PARAM_V2); + header = request.getHeader(AUTHORIZATION_HEADER_V2); } if (header == null) { // If there is NO auth header at all, let the JWT filter try to attempt Authentication and failure in the process. diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenAuthenticationProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenAuthenticationProvider.java index 7c11bd879b..6db0350bad 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenAuthenticationProvider.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenAuthenticationProvider.java @@ -28,28 +28,29 @@ import org.springframework.stereotype.Component; import org.springframework.util.Assert; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.UserAuthDetails; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.security.Authority; -import org.thingsboard.server.common.data.security.UserCredentials; import org.thingsboard.server.dao.customer.CustomerService; -import org.thingsboard.server.dao.user.UserService; import org.thingsboard.server.service.security.auth.RefreshAuthenticationToken; import org.thingsboard.server.service.security.auth.TokenOutdatingService; import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.model.UserPrincipal; import org.thingsboard.server.service.security.model.token.JwtTokenFactory; import org.thingsboard.server.service.security.model.token.RawAccessJwtToken; +import org.thingsboard.server.service.user.cache.UserAuthDetailsCache; import java.util.UUID; @Component @RequiredArgsConstructor public class RefreshTokenAuthenticationProvider implements AuthenticationProvider { + private final JwtTokenFactory tokenFactory; - private final UserService userService; + private final UserAuthDetailsCache userEnabledCache; private final CustomerService customerService; private final TokenOutdatingService tokenOutdatingService; @@ -61,7 +62,7 @@ public class RefreshTokenAuthenticationProvider implements AuthenticationProvide UserPrincipal principal = unsafeUser.getUserPrincipal(); SecurityUser securityUser; - if (principal.getType() == UserPrincipal.Type.USER_NAME) { + if (principal.getType() == UserPrincipal.Type.USER_NAME) { securityUser = authenticateByUserId(unsafeUser.getId()); } else { securityUser = authenticateByPublicId(principal.getValue()); @@ -75,26 +76,21 @@ public class RefreshTokenAuthenticationProvider implements AuthenticationProvide } private SecurityUser authenticateByUserId(UserId userId) { - TenantId systemId = TenantId.SYS_TENANT_ID; - User user = userService.findUserById(systemId, userId); - if (user == null) { - throw new UsernameNotFoundException("User not found by refresh token"); - } - - UserCredentials userCredentials = userService.findUserCredentialsByUserId(systemId, user.getId()); - if (userCredentials == null) { - throw new UsernameNotFoundException("User credentials not found"); + UserAuthDetails userAuthDetails = userEnabledCache.findUserEnabled(TenantId.SYS_TENANT_ID, userId); + if (userAuthDetails == null) { + throw new UsernameNotFoundException("User with credentials not found"); } - - if (!userCredentials.isEnabled()) { + if (!userAuthDetails.credentialsEnabled()) { throw new DisabledException("User is not active"); } - if (user.getAuthority() == null) throw new InsufficientAuthenticationException("User has no authority assigned"); + User user = userAuthDetails.user(); + if (user.getAuthority() == null) { + throw new InsufficientAuthenticationException("User has no authority assigned"); + } UserPrincipal userPrincipal = new UserPrincipal(UserPrincipal.Type.USER_NAME, user.getEmail()); - - return new SecurityUser(user, userCredentials.isEnabled(), userPrincipal); + return new SecurityUser(user, true, userPrincipal); } private SecurityUser authenticateByPublicId(String publicId) { diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProvider.java index 68a676ef18..86884a2eb4 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProvider.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProvider.java @@ -26,27 +26,25 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.User; -import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.UserAuthDetails; import org.thingsboard.server.common.data.pat.ApiKey; -import org.thingsboard.server.common.data.security.UserCredentials; import org.thingsboard.server.dao.pat.ApiKeyService; -import org.thingsboard.server.dao.user.UserService; import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.model.UserPrincipal; -import org.thingsboard.server.service.security.model.token.RawApiKeyToken; +import org.thingsboard.server.service.security.model.token.RawApiKey; +import org.thingsboard.server.service.user.cache.UserAuthDetailsCache; @Component @RequiredArgsConstructor public class ApiKeyAuthenticationProvider implements org.springframework.security.authentication.AuthenticationProvider { private final ApiKeyService apiKeyService; - private final UserService userService; + private final UserAuthDetailsCache userEnabledCache; @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { - RawApiKeyToken raw = (RawApiKeyToken) authentication.getCredentials(); - SecurityUser securityUser = authenticate(raw.token()); + RawApiKey rawApiKey = (RawApiKey) authentication.getCredentials(); + SecurityUser securityUser = authenticate(rawApiKey.apiKey()); return new ApiKeyAuthenticationToken(securityUser); } @@ -69,26 +67,20 @@ public class ApiKeyAuthenticationProvider implements org.springframework.securit if (apiKey.getExpirationTime() != 0 && apiKey.getExpirationTime() < System.currentTimeMillis()) { throw new CredentialsExpiredException("API key is expired"); } - TenantId tenantId = apiKey.getTenantId(); - UserId userId = apiKey.getUserId(); - User user = userService.findUserById(tenantId, userId); - if (user == null) { - throw new UsernameNotFoundException("User for the provided API key is no longer exists"); + UserAuthDetails userAuthDetails = userEnabledCache.findUserEnabled(apiKey.getTenantId(), apiKey.getUserId()); + if (userAuthDetails == null) { + throw new UsernameNotFoundException("User with credentials not found"); } - UserCredentials userCredentials = userService.findUserCredentialsByUserId(tenantId, userId); - if (userCredentials == null) { - throw new UsernameNotFoundException("User credentials not found"); - } - if (!userCredentials.isEnabled()) { + if (!userAuthDetails.credentialsEnabled()) { throw new DisabledException("User is not active"); } + + User user = userAuthDetails.user(); if (user.getAuthority() == null) { throw new InsufficientAuthenticationException("User has no authority assigned"); } - UserPrincipal userPrincipal = new UserPrincipal(UserPrincipal.Type.USER_NAME, user.getEmail()); - - return new SecurityUser(user, userCredentials.isEnabled(), userPrincipal); + return new SecurityUser(user, true, userPrincipal); } } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationToken.java b/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationToken.java index c6f686f204..8165baf483 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationToken.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationToken.java @@ -17,7 +17,7 @@ package org.thingsboard.server.service.security.auth.pat; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.thingsboard.server.service.security.model.SecurityUser; -import org.thingsboard.server.service.security.model.token.RawApiKeyToken; +import org.thingsboard.server.service.security.model.token.RawApiKey; import java.io.Serial; @@ -26,12 +26,12 @@ public class ApiKeyAuthenticationToken extends AbstractAuthenticationToken { @Serial private static final long serialVersionUID = 2978710889397403536L; - private RawApiKeyToken rawApiKeyToken; + private RawApiKey rawApiKey; private SecurityUser securityUser; - public ApiKeyAuthenticationToken(RawApiKeyToken raw) { + public ApiKeyAuthenticationToken(RawApiKey rawApiKey) { super(null); - this.rawApiKeyToken = raw; + this.rawApiKey = rawApiKey; setAuthenticated(false); } @@ -44,7 +44,7 @@ public class ApiKeyAuthenticationToken extends AbstractAuthenticationToken { @Override public Object getCredentials() { - return rawApiKeyToken; + return rawApiKey; } @Override @@ -55,7 +55,7 @@ public class ApiKeyAuthenticationToken extends AbstractAuthenticationToken { @Override public void eraseCredentials() { super.eraseCredentials(); - this.rawApiKeyToken = null; + this.rawApiKey = null; } } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyTokenAuthenticationProcessingFilter.java b/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyTokenAuthenticationProcessingFilter.java index b5f940f4cc..20a95a5ae5 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyTokenAuthenticationProcessingFilter.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyTokenAuthenticationProcessingFilter.java @@ -29,13 +29,13 @@ import org.springframework.security.web.authentication.AbstractAuthenticationPro import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.security.web.util.matcher.RequestMatcher; import org.thingsboard.server.service.security.auth.extractor.TokenExtractor; -import org.thingsboard.server.service.security.model.token.RawApiKeyToken; +import org.thingsboard.server.service.security.model.token.RawApiKey; import java.io.IOException; import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.API_KEY_HEADER_PREFIX; -import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.JWT_TOKEN_HEADER_PARAM; -import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.JWT_TOKEN_HEADER_PARAM_V2; +import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.AUTHORIZATION_HEADER; +import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.AUTHORIZATION_HEADER_V2; public class ApiKeyTokenAuthenticationProcessingFilter extends AbstractAuthenticationProcessingFilter { @@ -52,8 +52,8 @@ public class ApiKeyTokenAuthenticationProcessingFilter extends AbstractAuthentic @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { - RawApiKeyToken token = new RawApiKeyToken(tokenExtractor.extract(request)); - return getAuthenticationManager().authenticate(new ApiKeyAuthenticationToken(token)); + RawApiKey rawApiKey = new RawApiKey(tokenExtractor.extract(request)); + return getAuthenticationManager().authenticate(new ApiKeyAuthenticationToken(rawApiKey)); } @Override @@ -70,9 +70,9 @@ public class ApiKeyTokenAuthenticationProcessingFilter extends AbstractAuthentic if (!super.requiresAuthentication(request, response)) { return false; } - String header = request.getHeader(JWT_TOKEN_HEADER_PARAM); + String header = request.getHeader(AUTHORIZATION_HEADER); if (header == null) { - header = request.getHeader(JWT_TOKEN_HEADER_PARAM_V2); + header = request.getHeader(AUTHORIZATION_HEADER_V2); } return header != null && header.startsWith(API_KEY_HEADER_PREFIX); } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationProvider.java index 510278b25a..4450e624ec 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationProvider.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationProvider.java @@ -82,11 +82,10 @@ public class RestAuthenticationProvider implements AuthenticationProvider { Assert.notNull(authentication, "No authentication data provided"); Object principal = authentication.getPrincipal(); - if (!(principal instanceof UserPrincipal)) { + if (!(principal instanceof UserPrincipal userPrincipal)) { throw new BadCredentialsException("Authentication Failed. Bad user principal."); } - UserPrincipal userPrincipal = (UserPrincipal) principal; SecurityUser securityUser; if (userPrincipal.getType() == UserPrincipal.Type.USER_NAME) { String username = userPrincipal.getValue(); diff --git a/application/src/main/java/org/thingsboard/server/service/security/model/token/RawApiKeyToken.java b/application/src/main/java/org/thingsboard/server/service/security/model/token/RawApiKey.java similarity index 93% rename from application/src/main/java/org/thingsboard/server/service/security/model/token/RawApiKeyToken.java rename to application/src/main/java/org/thingsboard/server/service/security/model/token/RawApiKey.java index e361c48bc8..1268df592f 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/model/token/RawApiKeyToken.java +++ b/application/src/main/java/org/thingsboard/server/service/security/model/token/RawApiKey.java @@ -15,6 +15,4 @@ */ package org.thingsboard.server.service.security.model.token; -public record RawApiKeyToken(String token) { - -} +public record RawApiKey(String apiKey) {} diff --git a/application/src/main/java/org/thingsboard/server/service/ttl/ApiKeysCleanUpService.java b/application/src/main/java/org/thingsboard/server/service/ttl/ApiKeysCleanUpService.java index b394b93c09..c677db7108 100644 --- a/application/src/main/java/org/thingsboard/server/service/ttl/ApiKeysCleanUpService.java +++ b/application/src/main/java/org/thingsboard/server/service/ttl/ApiKeysCleanUpService.java @@ -16,7 +16,6 @@ package org.thingsboard.server.service.ttl; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; @@ -24,8 +23,6 @@ import org.thingsboard.server.dao.pat.ApiKeyDao; import org.thingsboard.server.queue.discovery.PartitionService; import org.thingsboard.server.queue.util.TbCoreComponent; -import java.util.concurrent.TimeUnit; - @Slf4j @Service @TbCoreComponent @@ -35,9 +32,6 @@ public class ApiKeysCleanUpService extends AbstractCleanUpService { public static final String RANDOM_DELAY_INTERVAL_MS_EXPRESSION = "#{T(org.apache.commons.lang3.RandomUtils).nextLong(0, ${sql.ttl.api_keys.checking_interval_ms})}"; - @Value("${sql.ttl.api_keys.ttl:2592000}") - private long ttl; - private final ApiKeyDao apiKeyDao; public ApiKeysCleanUpService(PartitionService partitionService, ApiKeyDao apiKeyDao) { @@ -50,7 +44,7 @@ public class ApiKeysCleanUpService extends AbstractCleanUpService { fixedDelayString = "${sql.ttl.api_keys.checking_interval_ms:86400000}" ) public void cleanUp() { - long threshold = System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(ttl); + long threshold = System.currentTimeMillis(); if (isSystemTenantPartitionMine()) { int deleted = apiKeyDao.deleteAllByExpirationTimeBefore(threshold); if (deleted > 0) { diff --git a/application/src/main/java/org/thingsboard/server/service/user/cache/DefaultUserAuthDetailsCache.java b/application/src/main/java/org/thingsboard/server/service/user/cache/DefaultUserAuthDetailsCache.java new file mode 100644 index 0000000000..21c9c9069d --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/user/cache/DefaultUserAuthDetailsCache.java @@ -0,0 +1,85 @@ +/** + * Copyright © 2016-2025 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.service.user.cache; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.UserAuthDetails; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; +import org.thingsboard.server.dao.user.UserService; + +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +@Slf4j +@Service +@RequiredArgsConstructor +public class DefaultUserAuthDetailsCache implements UserAuthDetailsCache { + + private final UserService userService; + + @Value("${cache.userEnabled.maxSize:1000}") + private int cacheMaxSize; + @Value("${cache.userEnabled.timeToLiveInMinutes:30}") + private int cacheValueTtl; + private Cache cache; + + private final ReadWriteLock lock = new ReentrantReadWriteLock(); + + @PostConstruct + private void init() { + cache = Caffeine.newBuilder() + .maximumSize(cacheMaxSize) + .expireAfterAccess(cacheValueTtl, TimeUnit.MINUTES) + .build(); + } + + @EventListener(ComponentLifecycleMsg.class) + public void onComponentLifecycleEvent(ComponentLifecycleMsg event) { + if (event.getEntityId() != null) { + if (event.getEntityId().getEntityType() == EntityType.USER) { + evict(new UserId(event.getEntityId().getId())); + } + } + } + + @Override + public UserAuthDetails findUserEnabled(TenantId tenantId, UserId userId) { + lock.readLock().lock(); + try { + log.trace("Retrieving user with enabled credentials status with id {} for tenant {} from cache", userId, tenantId); + return cache.get(userId, id -> userService.findUserAuthDetailsByUserId(tenantId, id)); + } finally { + lock.readLock().unlock(); + } + } + + public void evict(UserId userId) { + cache.invalidate(userId); + log.trace("Evicted record for user {} from cache", userId); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/user/cache/UserAuthDetailsCache.java b/application/src/main/java/org/thingsboard/server/service/user/cache/UserAuthDetailsCache.java new file mode 100644 index 0000000000..6363ccae67 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/user/cache/UserAuthDetailsCache.java @@ -0,0 +1,26 @@ +/** + * Copyright © 2016-2025 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.service.user.cache; + +import org.thingsboard.server.common.data.UserAuthDetails; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; + +public interface UserAuthDetailsCache { + + UserAuthDetails findUserEnabled(TenantId tenantId, UserId userId); + +} diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 361c584492..8884a66cc8 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -151,6 +151,12 @@ security: tokenSigningKey: "${JWT_TOKEN_SIGNING_KEY:thingsboardDefaultSigningKey}" # Base64 encoded # Enable/disable access to Tenant Administrators JWT token by System Administrator or Customer Users JWT token by Tenant Administrator user_token_access_enabled: "${SECURITY_USER_TOKEN_ACCESS_ENABLED:true}" + # API key parameters + api_key: + # Prefix for the auto-generated API key. For example, tb_Ood4dQMxWvMH-76z3E_Cv0mZaBWT0Clk3hRSO0P_jNQ + value_prefix: "${SECURITY_API_KEY_VALUE_PREFIX:tb_}" + # Length of the auto-generated API key. Max is 255 + value_bytes_size: "${SECURITY_API_KEY_VALUE_PREFIX:64}" # Enable/disable case-sensitive username login user_login_case_sensitive: "${SECURITY_USER_LOGIN_CASE_SENSITIVE:true}" claim: @@ -429,7 +435,6 @@ sql: checking_interval_ms: "${SQL_TTL_NOTIFICATIONS_CHECKING_INTERVAL_MS:86400000}" # Default value - 1 day api_keys: enabled: "${SQL_TTL_API_KEYS_ENABLED:true}" # Enable/disable TTL (Time To Live) for api keys records - ttl: "${SQL_TTL_API_KEYS_SECS:2592000}" # Default value - 30 days checking_interval_ms: "${SQL_TTL_API_KEYS_CHECKING_INTERVAL_MS:86400000}" # Default value - 1 day relations: max_level: "${SQL_RELATIONS_MAX_LEVEL:50}" # This value has to be reasonably small to prevent infinite recursion as early as possible @@ -666,6 +671,9 @@ cache: aiModel: timeToLiveInMinutes: "${CACHE_SPECS_AI_MODEL_TTL:1440}" # AI model cache TTL maxSize: "${CACHE_SPECS_AI_MODEL_MAX_SIZE:10000}" # 0 means the cache is disabled + apiKeys: + timeToLiveInMinutes: "${CACHE_SPECS_API_KEYS_TTL:1440}" # API keys cache TTL + maxSize: "${CACHE_SPECS_API_KEYS_MAX_SIZE:10000}" # 0 means the cache is disabled # Deliberately placed outside the 'specs' group above notificationRules: @@ -683,6 +691,9 @@ cache: maxSize: "${CACHE_SPECS_IMAGE_ETAGS_MAX_SIZE:10000}" # 0 means the cache is disabled systemImagesBrowserTtlInMinutes: "${CACHE_SPECS_IMAGE_SYSTEM_BROWSER_TTL:0}" # Browser cache TTL for system images in minutes. 0 means the cache is disabled tenantImagesBrowserTtlInMinutes: "${CACHE_SPECS_IMAGE_TENANT_BROWSER_TTL:0}" # Browser cache TTL for tenant images in minutes. 0 means the cache is disabled + userEnabled: + timeToLiveInMinutes: "${CACHE_SPECS_USER_ENABLED_TTL:120}" # User enabled cache TTL + maxSize: "${CACHE_SPECS_USER_ENABLED_MAX_SIZE:200000}" # 0 means the cache is disabled # Spring data parameters spring.data.redis.repositories.enabled: false # Disable this because it is not required. 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 fd01581e36..6da301062c 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java @@ -192,6 +192,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.setup.MockMvcBuilders.webAppContextSetup; import static org.thingsboard.server.common.data.CacheConstants.CLAIM_DEVICES_CACHE; +import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.API_KEY_HEADER_PREFIX; +import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.BEARER_HEADER_PREFIX; @Slf4j public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { @@ -237,6 +239,8 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { protected String mobileToken; protected String username; + protected String apiKey; + protected TenantId tenantId; protected TenantProfileId tenantProfileId; protected UserId tenantAdminUserId; @@ -631,13 +635,27 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { protected void setJwtToken(MockHttpServletRequestBuilder request) { if (this.token != null) { - request.header(ThingsboardSecurityConfiguration.JWT_TOKEN_HEADER_PARAM, "Bearer " + this.token); + request.header(ThingsboardSecurityConfiguration.AUTHORIZATION_HEADER, BEARER_HEADER_PREFIX + this.token); } if (this.mobileToken != null) { request.header(UserController.MOBILE_TOKEN_HEADER, this.mobileToken); } } + protected void resetApiKey() { + this.apiKey = null; + } + + protected void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + protected void setApiKey(MockHttpServletRequestBuilder request) { + if (this.apiKey != null) { + request.header(ThingsboardSecurityConfiguration.AUTHORIZATION_HEADER, API_KEY_HEADER_PREFIX + this.apiKey); + } + } + protected DeviceProfile createDeviceProfile(String name) { return createDeviceProfile(name, null); } @@ -744,6 +762,12 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { return mockMvc.perform(getRequest); } + protected ResultActions doGetWithApiKey(String urlTemplate, Object... urlVariables) throws Exception { + MockHttpServletRequestBuilder getRequest = get(urlTemplate, urlVariables); + setApiKey(getRequest); + return mockMvc.perform(getRequest); + } + protected T doGet(String urlTemplate, Class responseClass, Object... urlVariables) throws Exception { return readResponse(doGet(urlTemplate, urlVariables).andExpect(status().isOk()), responseClass); } @@ -767,6 +791,10 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { return mockMvc.perform(asyncDispatch(mockMvc.perform(getRequest).andExpect(request().asyncStarted()).andReturn())); } + protected T doGetWithApiKey(String urlTemplate, Class responseClass, Object... urlVariables) throws Exception { + return readResponse(doGetWithApiKey(urlTemplate, urlVariables).andExpect(status().isOk()), responseClass); + } + protected T doGetTyped(String urlTemplate, TypeReference responseType, Object... urlVariables) throws Exception { return readResponse(doGet(urlTemplate, urlVariables).andExpect(status().isOk()), responseType); } @@ -846,6 +874,14 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { } } + protected R doPostWithApiKey(String urlTemplate, T content, Class responseClass, String... params) { + try { + return readResponse(doPostWithApiKey(urlTemplate, content, params).andExpect(status().isOk()), responseClass); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + protected R doPostWithResponse(String urlTemplate, T content, Class responseClass, String... params) throws Exception { return readResponse(doPost(urlTemplate, content, params).andExpect(status().isOk()), responseClass); } @@ -913,6 +949,14 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { return mockMvc.perform(postRequest); } + protected ResultActions doPostWithApiKey(String urlTemplate, T content, String... params) throws Exception { + MockHttpServletRequestBuilder postRequest = post(urlTemplate, params); + setApiKey(postRequest); + String json = json(content); + postRequest.contentType(contentType).content(json); + return mockMvc.perform(postRequest); + } + protected ResultActions doPostAsync(String urlTemplate, T content, Long timeout, String... params) throws Exception { MockHttpServletRequestBuilder postRequest = post(urlTemplate, params); setJwtToken(postRequest); @@ -930,6 +974,13 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { return mockMvc.perform(deleteRequest); } + protected ResultActions doDeleteWithApiKey(String urlTemplate, String... params) throws Exception { + MockHttpServletRequestBuilder deleteRequest = delete(urlTemplate); + setApiKey(deleteRequest); + populateParams(deleteRequest, params); + return mockMvc.perform(deleteRequest); + } + protected ResultActions doDeleteAsync(String urlTemplate, Long timeout, String... params) throws Exception { MockHttpServletRequestBuilder deleteRequest = delete(urlTemplate, params); setJwtToken(deleteRequest); @@ -1300,7 +1351,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { protected List findJobs(List types, List entities) throws Exception { return doGetTypedWithPageLink("/api/jobs?types=" + types.stream().map(Enum::name).collect(Collectors.joining(",")) + - "&entities=" + entities.stream().map(UUID::toString).collect(Collectors.joining(",")) + "&", + "&entities=" + entities.stream().map(UUID::toString).collect(Collectors.joining(",")) + "&", new TypeReference>() {}, new PageLink(100, 0, null, new SortOrder("createdTime", SortOrder.Direction.DESC))).getData(); } diff --git a/application/src/test/java/org/thingsboard/server/controller/ApiKeyControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/ApiKeyControllerTest.java index d71d73b6df..167839e7f3 100644 --- a/application/src/test/java/org/thingsboard/server/controller/ApiKeyControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/ApiKeyControllerTest.java @@ -28,7 +28,6 @@ import org.thingsboard.server.dao.service.DaoSqlTest; import java.util.UUID; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.API_KEY_HEADER_PREFIX; @DaoSqlTest public class ApiKeyControllerTest extends AbstractControllerTest { @@ -42,8 +41,7 @@ public class ApiKeyControllerTest extends AbstractControllerTest { public void testSaveApiKey() throws Exception { ApiKeyInfo apiKeyInfo = constructApiKeyInfo("New API key description", true); - String apiKeyStr = doPost("/api/apiKey", apiKeyInfo, ApiKey.class).getValue(); - Assert.assertTrue(apiKeyStr.startsWith(API_KEY_HEADER_PREFIX)); + doPost("/api/apiKey", apiKeyInfo, ApiKey.class); PageData pageData = doGetTypedWithPageLink("/api/apiKeys/" + tenantAdminUserId + "?", new TypeReference<>() {}, new PageLink(10, 0)); Assert.assertEquals(1, pageData.getData().size()); diff --git a/application/src/test/java/org/thingsboard/server/service/security/auth/TokenOutdatingTest.java b/application/src/test/java/org/thingsboard/server/service/security/auth/TokenOutdatingTest.java index 49e4ed706c..3726a0acff 100644 --- a/application/src/test/java/org/thingsboard/server/service/security/auth/TokenOutdatingTest.java +++ b/application/src/test/java/org/thingsboard/server/service/security/auth/TokenOutdatingTest.java @@ -30,15 +30,14 @@ import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.junit4.SpringRunner; import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.UserAuthDetails; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.security.Authority; -import org.thingsboard.server.common.data.security.UserCredentials; import org.thingsboard.server.common.data.security.event.UserCredentialsInvalidationEvent; import org.thingsboard.server.common.data.security.event.UserSessionInvalidationEvent; import org.thingsboard.server.common.data.security.model.JwtToken; import org.thingsboard.server.dao.customer.CustomerService; import org.thingsboard.server.dao.service.DaoSqlTest; -import org.thingsboard.server.dao.user.UserService; import org.thingsboard.server.service.security.auth.jwt.JwtAuthenticationProvider; import org.thingsboard.server.service.security.auth.jwt.RefreshTokenAuthenticationProvider; import org.thingsboard.server.service.security.exception.JwtExpiredTokenException; @@ -46,6 +45,7 @@ import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.model.UserPrincipal; import org.thingsboard.server.service.security.model.token.JwtTokenFactory; import org.thingsboard.server.service.security.model.token.RawAccessJwtToken; +import org.thingsboard.server.service.user.cache.UserAuthDetailsCache; import java.util.UUID; @@ -91,20 +91,16 @@ public class TokenOutdatingTest { UserId userId = new UserId(UUID.randomUUID()); securityUser = createMockSecurityUser(userId); - UserService userService = mock(UserService.class); + UserAuthDetailsCache userAuthDetailsCache = mock(UserAuthDetailsCache.class); User user = new User(); user.setId(userId); user.setAuthority(Authority.TENANT_ADMIN); user.setEmail("email"); - when(userService.findUserById(any(), eq(userId))).thenReturn(user); - - UserCredentials userCredentials = new UserCredentials(); - userCredentials.setEnabled(true); - when(userService.findUserCredentialsByUserId(any(), eq(userId))).thenReturn(userCredentials); + when(userAuthDetailsCache.findUserEnabled(any(), eq(userId))).thenReturn(new UserAuthDetails(user, true)); accessTokenAuthenticationProvider = new JwtAuthenticationProvider(tokenFactory, tokenOutdatingService); - refreshTokenAuthenticationProvider = new RefreshTokenAuthenticationProvider(tokenFactory, userService, mock(CustomerService.class), tokenOutdatingService); + refreshTokenAuthenticationProvider = new RefreshTokenAuthenticationProvider(tokenFactory, userAuthDetailsCache, mock(CustomerService.class), tokenOutdatingService); } @Test diff --git a/application/src/test/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProviderTest.java b/application/src/test/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProviderTest.java index 00fac8866a..3c1b7dda18 100644 --- a/application/src/test/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProviderTest.java +++ b/application/src/test/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProviderTest.java @@ -15,176 +15,98 @@ */ package org.thingsboard.server.service.security.auth.pat; +import org.junit.After; +import org.junit.Assert; import org.junit.Before; import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.Mock; -import org.mockito.junit.MockitoJUnitRunner; -import org.springframework.security.authentication.BadCredentialsException; -import org.springframework.security.authentication.CredentialsExpiredException; -import org.springframework.security.authentication.DisabledException; -import org.springframework.security.authentication.InsufficientAuthenticationException; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.userdetails.UsernameNotFoundException; -import org.thingsboard.server.common.data.User; -import org.thingsboard.server.common.data.id.ApiKeyId; -import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.data.id.UserId; +import org.mockito.Mockito; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.pat.ApiKey; -import org.thingsboard.server.common.data.security.Authority; -import org.thingsboard.server.common.data.security.UserCredentials; -import org.thingsboard.server.dao.pat.ApiKeyService; -import org.thingsboard.server.dao.user.UserService; -import org.thingsboard.server.service.security.model.SecurityUser; -import org.thingsboard.server.service.security.model.token.RawApiKeyToken; +import org.thingsboard.server.common.data.pat.ApiKeyInfo; +import org.thingsboard.server.controller.AbstractControllerTest; +import org.thingsboard.server.dao.service.DaoSqlTest; -import java.util.UUID; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; -import static org.mockito.Mockito.when; +@DaoSqlTest +public class ApiKeyAuthenticationProviderTest extends AbstractControllerTest { -@RunWith(MockitoJUnitRunner.class) -public class ApiKeyAuthenticationProviderTest { - - private static final String TEST_API_KEY = "test_api_key"; - private static final String USER_EMAIL = "tenant@thingsboard.org"; - - @Mock - private ApiKeyService apiKeyService; - - @Mock - private UserService userService; - - private ApiKeyAuthenticationProvider provider; - private TenantId tenantId; - private UserId userId; - private User user; - private UserCredentials userCredentials; - private ApiKey apiKey; + ApiKey savedApiKey; @Before - public void setUp() { - provider = new ApiKeyAuthenticationProvider(apiKeyService, userService); - tenantId = TenantId.fromUUID(UUID.randomUUID()); - userId = new UserId(UUID.randomUUID()); - - user = new User(); - user.setId(userId); - user.setTenantId(tenantId); - user.setEmail(USER_EMAIL); - user.setAuthority(Authority.TENANT_ADMIN); - - userCredentials = new UserCredentials(); - userCredentials.setEnabled(true); - - apiKey = new ApiKey(); - apiKey.setId(new ApiKeyId(UUID.randomUUID())); - apiKey.setTenantId(tenantId); - apiKey.setUserId(userId); - apiKey.setValue(TEST_API_KEY); - apiKey.setEnabled(true); - apiKey.setExpirationTime(0); - } + public void setUp() throws Exception { + loginTenantAdmin(); - @Test - public void testSuccessfulAuthentication() { - when(apiKeyService.findApiKeyByValue(TEST_API_KEY)).thenReturn(apiKey); - when(userService.findUserById(tenantId, userId)).thenReturn(user); - when(userService.findUserCredentialsByUserId(tenantId, userId)).thenReturn(userCredentials); - - ApiKeyAuthenticationToken token = new ApiKeyAuthenticationToken(new RawApiKeyToken(TEST_API_KEY)); - - Authentication authentication = provider.authenticate(token); - - assertNotNull(authentication); - assertTrue(authentication.isAuthenticated()); - assertTrue(authentication instanceof ApiKeyAuthenticationToken); - SecurityUser securityUser = (SecurityUser) authentication.getPrincipal(); - assertEquals(userId, securityUser.getId()); - assertEquals(tenantId, securityUser.getTenantId()); - assertEquals(USER_EMAIL, securityUser.getEmail()); - assertEquals(Authority.TENANT_ADMIN, securityUser.getAuthority()); + ApiKeyInfo apiKeyInfo = constructApiKeyInfo(); + savedApiKey = doPost("/api/apiKey", apiKeyInfo, ApiKey.class); + setApiKey(savedApiKey.getValue()); } - @Test(expected = BadCredentialsException.class) - public void testEmptyApiKey() { - ApiKeyAuthenticationToken token = new ApiKeyAuthenticationToken(new RawApiKeyToken("")); - - provider.authenticate(token); + @After + public void cleanUp() throws Exception { + resetApiKey(); + doDelete("/api/apiKey/" + savedApiKey.getId()).andExpect(status().isOk()); } - @Test(expected = BadCredentialsException.class) - public void testNonExistentApiKey() { - when(apiKeyService.findApiKeyByValue(TEST_API_KEY)).thenReturn(null); - ApiKeyAuthenticationToken token = new ApiKeyAuthenticationToken(new RawApiKeyToken(TEST_API_KEY)); - - provider.authenticate(token); - } + @Test + public void testSaveEdgeWithApiKey() throws Exception { + Edge edge = constructEdge("My edge", "default"); - @Test(expected = DisabledException.class) - public void testDisabledApiKey() { - apiKey.setEnabled(false); - when(apiKeyService.findApiKeyByValue(TEST_API_KEY)).thenReturn(apiKey); - ApiKeyAuthenticationToken token = new ApiKeyAuthenticationToken(new RawApiKeyToken(TEST_API_KEY)); + Mockito.reset(tbClusterService, auditLogService); - provider.authenticate(token); - } + Edge savedEdge = doPostWithApiKey("/api/edge", edge, Edge.class); - @Test(expected = CredentialsExpiredException.class) - public void testExpiredApiKey() { - apiKey.setExpirationTime(System.currentTimeMillis() - 10000); // Expired 10 seconds ago - when(apiKeyService.findApiKeyByValue(TEST_API_KEY)).thenReturn(apiKey); - ApiKeyAuthenticationToken token = new ApiKeyAuthenticationToken(new RawApiKeyToken(TEST_API_KEY)); + Assert.assertNotNull(savedEdge); + Assert.assertNotNull(savedEdge.getId()); + Assert.assertTrue(savedEdge.getCreatedTime() > 0); + Assert.assertEquals(tenantId, savedEdge.getTenantId()); + Assert.assertNotNull(savedEdge.getCustomerId()); + Assert.assertEquals(NULL_UUID, savedEdge.getCustomerId().getId()); + Assert.assertEquals(edge.getName(), savedEdge.getName()); - provider.authenticate(token); - } + testNotifyEdgeStateChangeEventManyTimeMsgToEdgeServiceNever(savedEdge, savedEdge.getId(), savedEdge.getId(), + tenantId, tenantAdminUser.getCustomerId(), tenantAdminUser.getId(), tenantAdminUser.getEmail(), + ActionType.ADDED, 2); - @Test(expected = UsernameNotFoundException.class) - public void testNonExistentUser() { - when(apiKeyService.findApiKeyByValue(TEST_API_KEY)).thenReturn(apiKey); - when(userService.findUserById(tenantId, userId)).thenReturn(null); - ApiKeyAuthenticationToken token = new ApiKeyAuthenticationToken(new RawApiKeyToken(TEST_API_KEY)); + savedEdge.setName("My new edge"); + doPostWithApiKey("/api/edge", savedEdge, Edge.class); - provider.authenticate(token); - } + Edge foundEdge = doGetWithApiKey("/api/edge/" + savedEdge.getId().getId().toString(), Edge.class); + Assert.assertEquals(foundEdge.getName(), savedEdge.getName()); - @Test(expected = UsernameNotFoundException.class) - public void testNonExistentUserCredentials() { - when(apiKeyService.findApiKeyByValue(TEST_API_KEY)).thenReturn(apiKey); - when(userService.findUserById(tenantId, userId)).thenReturn(user); - when(userService.findUserCredentialsByUserId(tenantId, userId)).thenReturn(null); - ApiKeyAuthenticationToken token = new ApiKeyAuthenticationToken(new RawApiKeyToken(TEST_API_KEY)); + testNotifyEdgeStateChangeEventManyTimeMsgToEdgeServiceNever(foundEdge, foundEdge.getId(), foundEdge.getId(), + tenantId, tenantAdminUser.getCustomerId(), tenantAdminUser.getId(), tenantAdminUser.getEmail(), + ActionType.UPDATED, 1); - provider.authenticate(token); + doDeleteWithApiKey("/api/edge/" + savedEdge.getId().getId().toString()) + .andExpect(status().isOk()); } - @Test(expected = DisabledException.class) - public void testDisabledUser() { - userCredentials.setEnabled(false); - when(apiKeyService.findApiKeyByValue(TEST_API_KEY)).thenReturn(apiKey); - when(userService.findUserById(tenantId, userId)).thenReturn(user); - when(userService.findUserCredentialsByUserId(tenantId, userId)).thenReturn(userCredentials); - ApiKeyAuthenticationToken token = new ApiKeyAuthenticationToken(new RawApiKeyToken(TEST_API_KEY)); - - provider.authenticate(token); + @Test + public void testUnauthorizedWhenKeyDisabled() throws Exception { + ApiKeyInfo disabledApiKeyInfo = doPut("/api/apiKey/" + savedApiKey.getId().getId() + "/enabled/false", Boolean.FALSE, ApiKeyInfo.class); + Assert.assertFalse(disabledApiKeyInfo.isEnabled()); + doGetWithApiKey("/api/admin/featuresInfo").andExpect(status().isUnauthorized()); } - @Test(expected = InsufficientAuthenticationException.class) - public void testUserWithoutAuthority() { - user.setAuthority(null); - when(apiKeyService.findApiKeyByValue(TEST_API_KEY)).thenReturn(apiKey); - when(userService.findUserById(tenantId, userId)).thenReturn(user); - when(userService.findUserCredentialsByUserId(tenantId, userId)).thenReturn(userCredentials); - ApiKeyAuthenticationToken token = new ApiKeyAuthenticationToken(new RawApiKeyToken(TEST_API_KEY)); - - provider.authenticate(token); + @Test + public void testUnauthorizedWhenKeyExpired() throws Exception { + ApiKeyInfo apiKeyInfo = constructApiKeyInfo(); + apiKeyInfo.setExpirationTime(System.currentTimeMillis() - 1000); + ApiKey savedApiKeyWithBad = doPost("/api/apiKey", apiKeyInfo, ApiKey.class); + setApiKey(savedApiKeyWithBad.getValue()); + doPost("/api/apiKey", savedApiKey, ApiKeyInfo.class); + doGetWithApiKey("/api/admin/featuresInfo").andExpect(status().isUnauthorized()); } - @Test - public void testSupports() { - assertTrue(provider.supports(ApiKeyAuthenticationToken.class)); + private ApiKeyInfo constructApiKeyInfo() { + ApiKeyInfo apiKeyInfo = new ApiKeyInfo(); + apiKeyInfo.setDescription("New API key description"); + apiKeyInfo.setEnabled(true); + apiKeyInfo.setUserId(tenantAdminUserId); + return apiKeyInfo; } } diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java index c016631064..f82be54c56 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java @@ -17,6 +17,7 @@ package org.thingsboard.server.dao.user; import com.google.common.util.concurrent.ListenableFuture; import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.UserAuthDetails; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantProfileId; @@ -109,4 +110,6 @@ public interface UserService extends EntityDaoService { void removeMobileSession(TenantId tenantId, String mobileToken); + UserAuthDetails findUserAuthDetailsByUserId(TenantId tenantId, UserId userId); + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java b/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java index b55453f393..c97a3a9a21 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java @@ -40,6 +40,7 @@ public final class CacheConstants { public static final String SENT_NOTIFICATIONS_CACHE = "sentNotifications"; public static final String TRENDZ_SETTINGS_CACHE = "trendzSettings"; public static final String AI_MODEL_CACHE = "aiModel"; + public static final String API_KEYS_CACHE = "apiKeys"; public static final String ASSET_PROFILE_CACHE = "assetProfiles"; public static final String ATTRIBUTES_CACHE = "attributes"; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/UserAuthDetails.java b/common/data/src/main/java/org/thingsboard/server/common/data/UserAuthDetails.java new file mode 100644 index 0000000000..3bb05e8fee --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/UserAuthDetails.java @@ -0,0 +1,18 @@ +/** + * Copyright © 2016-2025 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; + +public record UserAuthDetails(User user, boolean credentialsEnabled) {} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/plugin/ComponentLifecycleMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/plugin/ComponentLifecycleMsg.java index d57301fd10..4350b2d66d 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/plugin/ComponentLifecycleMsg.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/plugin/ComponentLifecycleMsg.java @@ -30,9 +30,6 @@ import org.thingsboard.server.common.msg.cluster.ToAllNodesMsg; import java.io.Serial; import java.util.Optional; -/** - * @author Andrew Shvayka - */ @Data public class ComponentLifecycleMsg implements TenantAwareMsg, ToAllNodesMsg { diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/UserEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/UserEntity.java index c97fa00f80..5baa92f8c6 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/UserEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/UserEntity.java @@ -35,9 +35,6 @@ import org.thingsboard.server.dao.util.mapping.JsonConverter; import java.util.UUID; -/** - * Created by Valerii Sosliuk on 4/21/2017. - */ @Data @EqualsAndHashCode(callSuper = true) @Entity diff --git a/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyCacheKey.java b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyCacheKey.java new file mode 100644 index 0000000000..a655ac1c25 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyCacheKey.java @@ -0,0 +1,40 @@ +/** + * Copyright © 2016-2025 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.dao.pat; + +import org.checkerframework.checker.nullness.qual.NonNull; + +import java.io.Serializable; + +import static java.util.Objects.requireNonNull; + +record ApiKeyCacheKey(String value) implements Serializable { + + ApiKeyCacheKey { + requireNonNull(value); + } + + static ApiKeyCacheKey of(String value) { + return new ApiKeyCacheKey(value); + } + + @NonNull + @Override + public String toString() { + return /* cache name */ "_" + value; + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyCaffeineCache.java b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyCaffeineCache.java new file mode 100644 index 0000000000..48eab7a32c --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyCaffeineCache.java @@ -0,0 +1,33 @@ +/** + * Copyright © 2016-2025 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.dao.pat; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.cache.CacheManager; +import org.springframework.stereotype.Service; +import org.thingsboard.server.cache.CaffeineTbTransactionalCache; +import org.thingsboard.server.common.data.CacheConstants; +import org.thingsboard.server.common.data.pat.ApiKey; + +@ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "caffeine", matchIfMissing = true) +@Service("ApiKeyCache") +public class ApiKeyCaffeineCache extends CaffeineTbTransactionalCache { + + public ApiKeyCaffeineCache(CacheManager cacheManager) { + super(cacheManager, CacheConstants.API_KEYS_CACHE); + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyDao.java b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyDao.java index 73c57b2840..20448bb25c 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyDao.java @@ -20,13 +20,15 @@ import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.pat.ApiKey; import org.thingsboard.server.dao.Dao; +import java.util.Set; + public interface ApiKeyDao extends Dao { ApiKey findByValue(String value); - void deleteByTenantId(TenantId tenantId); + Set deleteByTenantId(TenantId tenantId); - void deleteByUserId(TenantId tenantId, UserId userId); + Set deleteByUserId(TenantId tenantId, UserId userId); int deleteAllByExpirationTimeBefore(long ts); diff --git a/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyEvictEvent.java b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyEvictEvent.java new file mode 100644 index 0000000000..d39149f11a --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyEvictEvent.java @@ -0,0 +1,18 @@ +/** + * Copyright © 2016-2025 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.dao.pat; + +public record ApiKeyEvictEvent(String value) {} diff --git a/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyRedisCache.java b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyRedisCache.java new file mode 100644 index 0000000000..eee0b8dc31 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyRedisCache.java @@ -0,0 +1,36 @@ +/** + * Copyright © 2016-2025 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.dao.pat; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.stereotype.Service; +import org.thingsboard.server.cache.CacheSpecsMap; +import org.thingsboard.server.cache.RedisTbTransactionalCache; +import org.thingsboard.server.cache.TBRedisCacheConfiguration; +import org.thingsboard.server.cache.TbJsonRedisSerializer; +import org.thingsboard.server.common.data.CacheConstants; +import org.thingsboard.server.common.data.pat.ApiKey; + +@ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "redis") +@Service("ApiKeyCache") +public class ApiKeyRedisCache extends RedisTbTransactionalCache { + + public ApiKeyRedisCache(TBRedisCacheConfiguration configuration, CacheSpecsMap cacheSpecsMap, RedisConnectionFactory connectionFactory) { + super(CacheConstants.API_KEYS_CACHE, cacheSpecsMap, connectionFactory, configuration, new TbJsonRedisSerializer<>(ApiKey.class)); + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyServiceImpl.java index cf4d4209af..682de95e78 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyServiceImpl.java @@ -17,7 +17,10 @@ package org.thingsboard.server.dao.pat; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; +import org.springframework.transaction.event.TransactionalEventListener; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.id.ApiKeyId; @@ -29,10 +32,12 @@ import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.pat.ApiKey; import org.thingsboard.server.common.data.pat.ApiKeyInfo; -import org.thingsboard.server.dao.entity.AbstractEntityService; +import org.thingsboard.server.dao.entity.AbstractCachedEntityService; +import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; import org.thingsboard.server.dao.service.validator.ApiKeyDataValidator; import java.util.Optional; +import java.util.Set; import java.util.UUID; import static org.thingsboard.server.dao.service.Validator.validateId; @@ -42,15 +47,28 @@ import static org.thingsboard.server.dao.user.UserServiceImpl.INCORRECT_USER_ID; @Slf4j @Service @RequiredArgsConstructor -public class ApiKeyServiceImpl extends AbstractEntityService implements ApiKeyService { +public class ApiKeyServiceImpl extends AbstractCachedEntityService implements ApiKeyService { private static final String INCORRECT_API_KEY_ID = "Incorrect ApiKeyId "; - private static final int DEFAULT_API_KEY_BYTES = 32; + private static final int MAX_API_KEY_VALUE_LENGTH = 255; private final ApiKeyDao apiKeyDao; private final ApiKeyInfoDao apiKeyInfoDao; + @Lazy private final ApiKeyDataValidator apiKeyValidator; + @Value("${security.api_key.value_prefix:}") + private String prefix; + + @Value("${security.api_key.value_bytes_size:}") + private int valueBytesSize; + + @Override + @TransactionalEventListener + public void handleEvictEvent(ApiKeyEvictEvent event) { + cache.evict(ApiKeyCacheKey.of(event.value())); + } + @Override public ApiKey saveApiKey(TenantId tenantId, ApiKeyInfo apiKeyInfo) { log.trace("Executing saveApiKey [{}]", apiKeyInfo); @@ -58,14 +76,19 @@ public class ApiKeyServiceImpl extends AbstractEntityService implements ApiKeySe var apiKey = new ApiKey(apiKeyInfo); var old = apiKeyValidator.validate(apiKey, ApiKeyInfo::getTenantId); if (old == null) { - String hash = generateApiKeySecret(); - apiKey.setValue(hash); + String value = generateApiKeySecret(); + apiKey.setValue(value); } else { apiKey.setValue(old.getValue()); } - return apiKeyDao.save(tenantId, apiKey); + var savedApiKey = apiKeyDao.save(tenantId, apiKey); + eventPublisher.publishEvent(SaveEntityEvent.builder().tenantId(tenantId).entityId(savedApiKey.getId()).entity(savedApiKey).created(apiKey.getId() == null).build()); + if (old != null && old.isEnabled() != apiKey.isEnabled()) { + publishEvictEvent(new ApiKeyEvictEvent(apiKey.getValue())); + } + return savedApiKey; } catch (Exception e) { - checkConstraintViolation(e, "api_hash_unq_key", "Api Key with such hash already exists!"); + checkConstraintViolation(e, "api_key_value_unq_key", "Api Key with such value already exists!"); throw e; } } @@ -91,46 +114,37 @@ public class ApiKeyServiceImpl extends AbstractEntityService implements ApiKeySe @Override public void deleteApiKey(TenantId tenantId, ApiKey apiKey, boolean force) { - deleteApiKey(tenantId, apiKey.getId()); - } - - @Override - public void deleteEntity(TenantId tenantId, EntityId id, boolean force) { - deleteApiKey(tenantId, id); - } - - private void deleteApiKey(TenantId tenantId, EntityId entityId) { - UUID apiKeyId = entityId.getId(); + UUID apiKeyId = apiKey.getUuidId(); validateId(apiKeyId, id -> INCORRECT_API_KEY_ID + id); - ApiKey apiKey = apiKeyDao.findById(tenantId, apiKeyId); - if (apiKey == null) { - return; - } apiKeyDao.removeById(tenantId, apiKeyId); + publishEvictEvent(new ApiKeyEvictEvent(apiKey.getValue())); } @Override public void deleteByTenantId(TenantId tenantId) { log.trace("Executing deleteApiKeysByTenantId, tenantId [{}]", tenantId); validateId(tenantId, id -> INCORRECT_TENANT_ID + id); - apiKeyDao.deleteByTenantId(tenantId); + Set values = apiKeyDao.deleteByTenantId(tenantId); + values.forEach(value -> publishEvictEvent(new ApiKeyEvictEvent(value))); } @Override public void deleteByUserId(TenantId tenantId, UserId userId) { log.trace("Executing deleteApiKeysByUserId, tenantId [{}]", tenantId); validateId(userId, id -> INCORRECT_USER_ID + id); - apiKeyDao.deleteByUserId(tenantId, userId); + Set values = apiKeyDao.deleteByUserId(tenantId, userId); + values.forEach(value -> publishEvictEvent(new ApiKeyEvictEvent(value))); } @Override public ApiKey findApiKeyByValue(String value) { log.trace("Executing findApiKeyByValue [{}]", value); - return apiKeyDao.findByValue(value); + var cacheKey = ApiKeyCacheKey.of(value); + return cache.getAndPutInTransaction(cacheKey, () -> apiKeyDao.findByValue(value), true); } - private static String generateApiKeySecret() { - return StringUtils.generateSafeToken(DEFAULT_API_KEY_BYTES); + private String generateApiKeySecret() { + return prefix + StringUtils.generateSafeToken(Math.min(valueBytesSize, MAX_API_KEY_VALUE_LENGTH)); } @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/ApiKeyDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/ApiKeyDataValidator.java index a1431846f7..e539c1e721 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/ApiKeyDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/ApiKeyDataValidator.java @@ -22,22 +22,22 @@ import org.thingsboard.server.common.data.pat.ApiKey; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.pat.ApiKeyDao; import org.thingsboard.server.dao.service.DataValidator; -import org.thingsboard.server.dao.tenant.TenantDao; -import org.thingsboard.server.dao.user.UserDao; +import org.thingsboard.server.dao.tenant.TenantService; +import org.thingsboard.server.dao.user.UserService; @Component @RequiredArgsConstructor public class ApiKeyDataValidator extends DataValidator { private final ApiKeyDao apiKeyDao; - private final TenantDao tenantDao; - private final UserDao userDao; + private final TenantService tenantService; + private final UserService userService; @Override protected void validateDataImpl(TenantId tenantId, ApiKey apiKey) { if (apiKey.getId() != null) { if (apiKey.getUuidId() == null) { - throw new DataValidationException("Api Key UUID should be specified!"); + throw new DataValidationException("API Key UUID should be specified!"); } if (apiKey.getId().isNullUid()) { throw new DataValidationException("API key UUID must not be the reserved null value!"); @@ -47,14 +47,14 @@ public class ApiKeyDataValidator extends DataValidator { if (apiKey.getTenantId() == null || apiKey.getTenantId().getId() == null) { throw new DataValidationException("API key should be assigned to tenant!"); } - if (!TenantId.SYS_TENANT_ID.equals(apiKey.getTenantId()) && tenantDao.findById(apiKey.getTenantId(), apiKey.getTenantId().getId()) == null) { + if (!TenantId.SYS_TENANT_ID.equals(apiKey.getTenantId()) && !tenantService.tenantExists(apiKey.getTenantId())) { throw new DataValidationException("API key reference a non-existent tenant!"); } if (apiKey.getUserId() == null || apiKey.getUserId().getId() == null) { throw new DataValidationException("API key should be assigned to user!"); } - if (userDao.findById(apiKey.getTenantId(), apiKey.getUserId().getId()) == null) { + if (userService.findUserById(apiKey.getTenantId(), apiKey.getUserId()) == null) { throw new DataValidationException("API key reference a non-existent user!"); } } @@ -66,10 +66,10 @@ public class ApiKeyDataValidator extends DataValidator { throw new DataValidationException("Cannot update non-existent API key!"); } if (!old.getUserId().equals(apiKey.getUserId())) { - throw new DataValidationException("Cannot update api key user id!"); + throw new DataValidationException("Cannot update API key user id!"); } if (old.getExpirationTime() != apiKey.getExpirationTime()) { - throw new DataValidationException("Cannot update api key expiration time!"); + throw new DataValidationException("Cannot update API key expiration time!"); } return old; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyRepository.java index 2c797db7c1..8b96776d4d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyRepository.java @@ -22,6 +22,7 @@ import org.springframework.data.repository.query.Param; import org.springframework.transaction.annotation.Transactional; import org.thingsboard.server.dao.model.sql.ApiKeyEntity; +import java.util.Set; import java.util.UUID; public interface ApiKeyRepository extends JpaRepository { @@ -30,14 +31,24 @@ public interface ApiKeyRepository extends JpaRepository { @Transactional @Modifying - @Query("DELETE FROM ApiKeyEntity ak WHERE ak.tenantId = :tenantId") - void deleteByTenantId(@Param("tenantId") UUID tenantId); + @Query(value = """ + DELETE FROM api_key + WHERE tenant_id = :tenantId + RETURNING value + """, nativeQuery = true + ) + Set deleteByTenantId(@Param("tenantId") UUID tenantId); @Transactional @Modifying - @Query("DELETE FROM ApiKeyEntity ak WHERE ak.tenantId = :tenantId AND ak.userId = :userId") - void deleteByUserId(@Param("tenantId") UUID tenantId, - @Param("userId") UUID userId); + @Query(value = """ + DELETE FROM api_key + WHERE tenant_id = :tenantId AND user_id = :userId + RETURNING value + """, nativeQuery = true + ) + Set deleteByUserId(@Param("tenantId") UUID tenantId, + @Param("userId") UUID userId); @Transactional @Modifying diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/pat/JpaApiKeyDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/pat/JpaApiKeyDao.java index f539170798..76bd7e52b6 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/pat/JpaApiKeyDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/pat/JpaApiKeyDao.java @@ -46,13 +46,13 @@ public class JpaApiKeyDao extends JpaAbstractDao implement } @Override - public void deleteByTenantId(TenantId tenantId) { - apiKeyRepository.deleteByTenantId(tenantId.getId()); + public Set deleteByTenantId(TenantId tenantId) { + return apiKeyRepository.deleteByTenantId(tenantId.getId()); } @Override - public void deleteByUserId(TenantId tenantId, UserId userId) { - apiKeyRepository.deleteByUserId(tenantId.getId(), userId.getId()); + public Set deleteByUserId(TenantId tenantId, UserId userId) { + return apiKeyRepository.deleteByUserId(tenantId.getId(), userId.getId()); } @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/user/JpaUserDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/user/JpaUserDao.java index 35d15bab51..95a5604bfb 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/user/JpaUserDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/user/JpaUserDao.java @@ -21,6 +21,7 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.UserAuthDetails; import org.thingsboard.server.common.data.edqs.fields.UserFields; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; @@ -28,6 +29,7 @@ import org.thingsboard.server.common.data.id.TenantProfileId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.common.data.util.TbPair; import org.thingsboard.server.dao.DaoUtil; import org.thingsboard.server.dao.model.sql.UserEntity; import org.thingsboard.server.dao.sql.JpaAbstractDao; @@ -136,6 +138,12 @@ public class JpaUserDao extends JpaAbstractDao implements User DaoUtil.toPageable(pageLink))); } + @Override + public UserAuthDetails findUserAuthDetailsByUserId(UUID tenantId, UUID userId) { + TbPair result = userRepository.findUserAuthDetailsByUserId(userId); + return new UserAuthDetails(result.getFirst().toData(), result.getSecond()); + } + @Override public Long countByTenantId(TenantId tenantId) { return userRepository.countByTenantId(tenantId.getId()); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/user/UserRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/user/UserRepository.java index 0a30a859c6..5a806592de 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/user/UserRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/user/UserRepository.java @@ -23,15 +23,13 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.thingsboard.server.common.data.edqs.fields.UserFields; import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.common.data.util.TbPair; import org.thingsboard.server.dao.model.sql.UserEntity; import java.util.Collection; import java.util.List; import java.util.UUID; -/** - * @author Valerii Sosliuk - */ public interface UserRepository extends JpaRepository { UserEntity findByEmail(String email); @@ -78,4 +76,9 @@ public interface UserRepository extends JpaRepository { "u.customerId, u.version, u.firstName, u.lastName, u.email, u.phone, u.additionalInfo) " + "FROM UserEntity u WHERE u.id > :id ORDER BY u.id") List findNextBatch(@Param("id") UUID id, Limit limit); + + @Query("SELECT new org.thingsboard.server.common.data.util.TbPair(u, uc.enabled) " + + "FROM UserEntity u JOIN UserCredentialsEntity uc ON u.id = uc.userId WHERE u.id = :userId ") + TbPair findUserAuthDetailsByUserId(@Param("userId") UUID userId); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java index 0df7c36527..fa8b133765 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java @@ -171,7 +171,7 @@ public class TenantServiceImpl extends AbstractCachedEntityService, TenantEntityDao { PageData findByAuthorityAndTenantProfilesIds(Authority authority, List tenantProfilesIds, PageLink pageLink); + UserAuthDetails findUserAuthDetailsByUserId(UUID tenantId, UUID userId); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java index 88973b3f6c..04f5f0a46c 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java @@ -34,6 +34,7 @@ import org.thingsboard.server.cache.user.UserCacheKey; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.UserAuthDetails; import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.EntityId; @@ -296,7 +297,7 @@ public class UserServiceImpl extends AbstractCachedEntityService INCORRECT_USER_ID + id); + return userDao.findUserAuthDetailsByUserId(tenantId.getId(), userId.getId()); + } + private Optional findMobileSessionInfo(TenantId tenantId, UserId userId) { return Optional.ofNullable(userSettingsService.findUserSettings(tenantId, userId, UserSettingsType.MOBILE)) .map(UserSettings::getSettings).map(settings -> JacksonUtil.treeToValue(settings, UserMobileSessionInfo.class)); diff --git a/dao/src/main/resources/sql/schema-entities.sql b/dao/src/main/resources/sql/schema-entities.sql index 9d811250ac..b5bd181f54 100644 --- a/dao/src/main/resources/sql/schema-entities.sql +++ b/dao/src/main/resources/sql/schema-entities.sql @@ -714,11 +714,11 @@ CREATE TABLE IF NOT EXISTS api_key ( created_time bigint NOT NULL, tenant_id uuid, user_id uuid, - value varchar(255), + value varchar(512), enabled boolean NOT NULL DEFAULT TRUE, expiration_time bigint DEFAULT 0, description varchar(1024), - CONSTRAINT api_value_unq_key UNIQUE (value) + CONSTRAINT api_key_value_unq_key UNIQUE (value) ); CREATE TABLE IF NOT EXISTS resource ( 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 fcc83e1149..f6f91ca0b2 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 @@ -226,7 +226,7 @@ public class RestClient implements Closeable { private long refreshTokenExpTs; private long clientServerTimeDiff; - public enum AuthType { JWT, API_KEY } + public enum AuthType {JWT, API_KEY} public RestClient(String baseURL) { this(new RestTemplate(), baseURL); @@ -236,6 +236,10 @@ public class RestClient implements Closeable { this(restTemplate, baseURL, AuthType.JWT, null); } + public RestClient(RestTemplate restTemplate, String baseURL, String accessToken) { + this(restTemplate, baseURL, AuthType.JWT, accessToken); + } + public RestClient(RestTemplate restTemplate, String baseURL, AuthType authType, String token) { this.restTemplate = restTemplate; this.loginRestTemplate = new RestTemplate(restTemplate.getRequestFactory()); @@ -3012,7 +3016,7 @@ public class RestClient implements Closeable { addWidgetInfoFiltersToParams(tenantOnly, fullSearch, deprecatedFilter, widgetTypeList, params); return restTemplate.exchange( baseURL + "/api/widgetTypes?" + getUrlParams(pageLink) + - getWidgetTypeInfoPageRequestUrlParams(tenantOnly, fullSearch, deprecatedFilter, widgetTypeList), + getWidgetTypeInfoPageRequestUrlParams(tenantOnly, fullSearch, deprecatedFilter, widgetTypeList), HttpMethod.GET, HttpEntity.EMPTY, new ParameterizedTypeReference>() { @@ -3100,7 +3104,7 @@ public class RestClient implements Closeable { addWidgetInfoFiltersToParams(tenantOnly, fullSearch, deprecatedFilter, widgetTypeList, params); return restTemplate.exchange( baseURL + "/api/widgetTypesInfos?widgetsBundleId={widgetsBundleId}&" + getUrlParams(pageLink) + - getWidgetTypeInfoPageRequestUrlParams(tenantOnly, fullSearch, deprecatedFilter, widgetTypeList), + getWidgetTypeInfoPageRequestUrlParams(tenantOnly, fullSearch, deprecatedFilter, widgetTypeList), HttpMethod.GET, HttpEntity.EMPTY, new ParameterizedTypeReference>() { From 4fa5ad2ec51cbfb1f86eeb3ae79e0a8b84215c4e Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Wed, 15 Oct 2025 14:03:13 +0300 Subject: [PATCH 16/56] Renaming --- .../security/auth/jwt/RefreshTokenAuthenticationProvider.java | 4 ++-- .../security/auth/pat/ApiKeyAuthenticationProvider.java | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenAuthenticationProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenAuthenticationProvider.java index 6db0350bad..1aa6e046dd 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenAuthenticationProvider.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenAuthenticationProvider.java @@ -50,7 +50,7 @@ import java.util.UUID; public class RefreshTokenAuthenticationProvider implements AuthenticationProvider { private final JwtTokenFactory tokenFactory; - private final UserAuthDetailsCache userEnabledCache; + private final UserAuthDetailsCache userAuthDetailsCache; private final CustomerService customerService; private final TokenOutdatingService tokenOutdatingService; @@ -76,7 +76,7 @@ public class RefreshTokenAuthenticationProvider implements AuthenticationProvide } private SecurityUser authenticateByUserId(UserId userId) { - UserAuthDetails userAuthDetails = userEnabledCache.findUserEnabled(TenantId.SYS_TENANT_ID, userId); + UserAuthDetails userAuthDetails = userAuthDetailsCache.findUserEnabled(TenantId.SYS_TENANT_ID, userId); if (userAuthDetails == null) { throw new UsernameNotFoundException("User with credentials not found"); } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProvider.java index 86884a2eb4..bcb976e9d8 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProvider.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProvider.java @@ -39,7 +39,7 @@ import org.thingsboard.server.service.user.cache.UserAuthDetailsCache; public class ApiKeyAuthenticationProvider implements org.springframework.security.authentication.AuthenticationProvider { private final ApiKeyService apiKeyService; - private final UserAuthDetailsCache userEnabledCache; + private final UserAuthDetailsCache userAuthDetailsCache; @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { @@ -67,7 +67,7 @@ public class ApiKeyAuthenticationProvider implements org.springframework.securit if (apiKey.getExpirationTime() != 0 && apiKey.getExpirationTime() < System.currentTimeMillis()) { throw new CredentialsExpiredException("API key is expired"); } - UserAuthDetails userAuthDetails = userEnabledCache.findUserEnabled(apiKey.getTenantId(), apiKey.getUserId()); + UserAuthDetails userAuthDetails = userAuthDetailsCache.findUserEnabled(apiKey.getTenantId(), apiKey.getUserId()); if (userAuthDetails == null) { throw new UsernameNotFoundException("User with credentials not found"); } From b747908015fab765cb5140cd84d3ec975a5e168e Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Wed, 15 Oct 2025 14:37:01 +0300 Subject: [PATCH 17/56] Add credentials expired exception --- .../server/exception/ThingsboardErrorResponseHandler.java | 3 +++ .../service/security/exception/JwtExpiredTokenException.java | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponseHandler.java b/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponseHandler.java index 9364121961..56292dfa1b 100644 --- a/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponseHandler.java +++ b/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponseHandler.java @@ -32,6 +32,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.lang.Nullable; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.CredentialsExpiredException; import org.springframework.security.authentication.DisabledException; import org.springframework.security.authentication.LockedException; import org.springframework.security.core.AuthenticationException; @@ -242,6 +243,8 @@ public class ThingsboardErrorResponseHandler extends ResponseEntityExceptionHand JacksonUtil.writeValue(response.getWriter(), ThingsboardCredentialsExpiredResponse.of(expiredException.getMessage(), resetToken)); } else if (authenticationException instanceof UserPasswordNotValidException expiredException) { JacksonUtil.writeValue(response.getWriter(), ThingsboardCredentialsViolationResponse.of(expiredException.getMessage())); + } else if (authenticationException instanceof CredentialsExpiredException credentialsExpiredException) { + JacksonUtil.writeValue(response.getWriter(), ThingsboardCredentialsViolationResponse.of(credentialsExpiredException.getMessage(), ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED)); } else { JacksonUtil.writeValue(response.getWriter(), ThingsboardErrorResponse.of("Authentication failed", ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED)); } diff --git a/application/src/main/java/org/thingsboard/server/service/security/exception/JwtExpiredTokenException.java b/application/src/main/java/org/thingsboard/server/service/security/exception/JwtExpiredTokenException.java index 51951db254..7e6875503a 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/exception/JwtExpiredTokenException.java +++ b/application/src/main/java/org/thingsboard/server/service/security/exception/JwtExpiredTokenException.java @@ -17,7 +17,11 @@ package org.thingsboard.server.service.security.exception; import org.springframework.security.core.AuthenticationException; +import java.io.Serial; + public class JwtExpiredTokenException extends AuthenticationException { + + @Serial private static final long serialVersionUID = -5959543783324224864L; private String token; @@ -34,4 +38,5 @@ public class JwtExpiredTokenException extends AuthenticationException { public String token() { return this.token; } + } From ebaebae020435c210ccbc510a11236e92b522b63 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Wed, 15 Oct 2025 15:17:53 +0300 Subject: [PATCH 18/56] Revert application properties for apiKeys cache --- .../java/org/thingsboard/server/dao/pat/ApiKeyServiceImpl.java | 2 +- dao/src/test/resources/application-test.properties | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyServiceImpl.java index 682de95e78..13578f0555 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyServiceImpl.java @@ -60,7 +60,7 @@ public class ApiKeyServiceImpl extends AbstractCachedEntityService Date: Mon, 27 Oct 2025 15:58:51 +0200 Subject: [PATCH 19/56] added cf output strategy --- ...CalculatedFieldEntityMessageProcessor.java | 2 +- ...tractCalculatedFieldProcessingService.java | 77 +++++++++++++++++++ .../cf/AlarmCalculatedFieldResult.java | 7 ++ .../cf/CalculatedFieldProcessingService.java | 4 + .../service/cf/CalculatedFieldResult.java | 3 + ...faultCalculatedFieldProcessingService.java | 45 ++++++++--- .../cf/PropagationCalculatedFieldResult.java | 6 ++ .../cf/TelemetryCalculatedFieldResult.java | 2 + .../ctx/state/ScriptCalculatedFieldState.java | 1 + .../ctx/state/SimpleCalculatedFieldState.java | 1 + .../GeofencingCalculatedFieldState.java | 1 + .../PropagationCalculatedFieldState.java | 1 + .../cf/CalculatedFieldIntegrationTest.java | 45 +++++++++++ ...AttributeSkipRuleEngineOutputStrategy.java | 29 +++++++ .../common/data/cf/configuration/Output.java | 8 ++ .../data/cf/configuration/OutputStrategy.java | 37 +++++++++ .../cf/configuration/OutputStrategyType.java | 22 ++++++ .../PushToRuleEngineOutputStrategy.java | 25 ++++++ .../SkipRuleEngineOutputStrategy.java | 37 +++++++++ ...imeSeriesSkipRuleEngineOutputStrategy.java | 29 +++++++ 20 files changed, 372 insertions(+), 10 deletions(-) create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributeSkipRuleEngineOutputStrategy.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/OutputStrategy.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/OutputStrategyType.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/PushToRuleEngineOutputStrategy.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/SkipRuleEngineOutputStrategy.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesSkipRuleEngineOutputStrategy.java diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java index 182d815c96..c3eeb71c7b 100644 --- a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java @@ -406,7 +406,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM stateSizeChecked = true; if (state.isSizeOk()) { if (!calculationResult.isEmpty()) { - cfService.pushMsgToRuleEngine(tenantId, entityId, calculationResult, cfIdList, callback); + cfService.processResult(tenantId, entityId, calculationResult, cfIdList, callback); } else { callback.onSuccess(); } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java b/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java index 5115dbc079..a570640f5d 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java @@ -15,18 +15,28 @@ */ package org.thingsboard.server.service.cf; +import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListeningExecutorService; import com.google.common.util.concurrent.MoreExecutors; +import com.google.common.util.concurrent.SettableFuture; +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import lombok.Data; import lombok.extern.slf4j.Slf4j; import org.thingsboard.common.util.ThingsBoardExecutors; +import org.thingsboard.rule.engine.api.AttributesSaveRequest; +import org.thingsboard.rule.engine.api.TimeseriesSaveRequest; +import org.thingsboard.server.common.adaptor.JsonConverter; import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentType; +import org.thingsboard.server.common.data.cf.configuration.OutputType; import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration; +import org.thingsboard.server.common.data.cf.configuration.TimeSeriesSkipRuleEngineOutputStrategy; +import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.kv.Aggregation; @@ -34,9 +44,11 @@ import org.thingsboard.server.common.data.kv.AttributeKvEntry; import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; import org.thingsboard.server.common.data.kv.BaseReadTsKvQuery; import org.thingsboard.server.common.data.kv.BasicTsKvEntry; +import org.thingsboard.server.common.data.kv.KvEntry; import org.thingsboard.server.common.data.kv.ReadTsKvQuery; import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; +import org.thingsboard.server.common.msg.queue.TbCallback; import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.dao.timeseries.TimeseriesService; @@ -44,10 +56,13 @@ import org.thingsboard.server.dao.usagerecord.ApiLimitService; import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry; +import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.concurrent.ExecutionException; @@ -67,6 +82,7 @@ public abstract class AbstractCalculatedFieldProcessingService { protected final AttributesService attributesService; protected final TimeseriesService timeseriesService; + protected final TelemetrySubscriptionService tsSubService; protected final ApiLimitService apiLimitService; protected final RelationService relationService; protected final OwnerService ownerService; @@ -268,4 +284,65 @@ public abstract class AbstractCalculatedFieldProcessingService { return new BaseReadTsKvQuery(argument.getRefEntityKey().getKey(), startTs, endTs, 0, limit, Aggregation.NONE); } + protected void saveTelemetryResult(TenantId tenantId, EntityId entityId, TelemetryCalculatedFieldResult cfResult, List cfIds, TbCallback callback) { + OutputType type = cfResult.getType(); + JsonElement jsonResult = JsonParser.parseString(Objects.requireNonNull(cfResult.stringValue())); + + log.trace("[{}][{}] Saving CF result: {}", tenantId, entityId, jsonResult); + + SettableFuture future = SettableFuture.create(); + switch (type) { + case ATTRIBUTES -> saveAttributes(tenantId, entityId, jsonResult, cfIds, future); + case TIME_SERIES -> saveTimeSeries(tenantId, entityId, jsonResult, ((TimeSeriesSkipRuleEngineOutputStrategy) cfResult.getOutputStrategy()).getTtl(), cfIds, System.currentTimeMillis(), TimeseriesSaveRequest.Strategy.PROCESS_ALL, future); + } + + if (log.isTraceEnabled()) { + Futures.addCallback(future, new FutureCallback<>() { + @Override + public void onSuccess(Void v) { + callback.onSuccess(); + log.debug("[{}][{}] Saved CF result: {}", tenantId, entityId, cfResult); + } + + @Override + public void onFailure(Throwable t) { + callback.onFailure(t); + log.error("[{}][{}] Failed to save CF result {}", tenantId, entityId, cfResult, t); + } + }, MoreExecutors.directExecutor()); + } + } + + private void saveAttributes(TenantId tenantId, EntityId entityId, JsonElement jsonResult, List cfIds, SettableFuture future) { + List attributeKvEntries = JsonConverter.convertToAttributes(jsonResult); + tsSubService.saveAttributesInternal(AttributesSaveRequest.builder() + .tenantId(tenantId) + .entityId(entityId) + .entries(attributeKvEntries) + .strategy(AttributesSaveRequest.Strategy.PROCESS_ALL) + .previousCalculatedFieldIds(cfIds) + .future(future) + .build() + ); + } + + private void saveTimeSeries(TenantId tenantId, EntityId entityId, JsonElement jsonResult, Long ttl, List cfIds, long ts, TimeseriesSaveRequest.Strategy strategy, SettableFuture future) { + Map> tsKvMap = JsonConverter.convertToTelemetry(jsonResult, ts); + List tsEntries = new ArrayList<>(); + for (Map.Entry> tsKvEntry : tsKvMap.entrySet()) { + for (KvEntry kvEntry : tsKvEntry.getValue()) { + tsEntries.add(new BasicTsKvEntry(tsKvEntry.getKey(), kvEntry)); + } + } + tsSubService.saveTimeseriesInternal(TimeseriesSaveRequest.builder() + .tenantId(tenantId) + .entityId(entityId) + .entries(tsEntries) + .ttl(ttl) + .strategy(strategy) + .previousCalculatedFieldIds(cfIds) + .future(future) + .build()); + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/AlarmCalculatedFieldResult.java b/application/src/main/java/org/thingsboard/server/service/cf/AlarmCalculatedFieldResult.java index 498a215e17..3191b84193 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/AlarmCalculatedFieldResult.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/AlarmCalculatedFieldResult.java @@ -21,6 +21,8 @@ import lombok.RequiredArgsConstructor; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.action.TbAlarmResult; import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.cf.configuration.OutputStrategy; +import org.thingsboard.server.common.data.cf.configuration.PushToRuleEngineOutputStrategy; import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.msg.TbMsgType; @@ -36,6 +38,11 @@ public class AlarmCalculatedFieldResult implements CalculatedFieldResult { private final TbAlarmResult alarmResult; + @Override + public OutputStrategy getOutputStrategy() { + return new PushToRuleEngineOutputStrategy(); + } + @Override public TbMsg toTbMsg(EntityId entityId, List cfIds) { TbMsgType msgType; diff --git a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldProcessingService.java b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldProcessingService.java index a9139572b8..5473f3f4a9 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldProcessingService.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldProcessingService.java @@ -37,6 +37,10 @@ public interface CalculatedFieldProcessingService { Map fetchArgsFromDb(TenantId tenantId, EntityId entityId, Map arguments); + void saveToDB(TenantId tenantId, EntityId entityId, CalculatedFieldResult result, List cfIds, TbCallback callback); + + void processResult(TenantId tenantId, EntityId entityId, CalculatedFieldResult result, List cfIds, TbCallback callback); + void pushMsgToRuleEngine(TenantId tenantId, EntityId entityId, CalculatedFieldResult result, List cfIds, TbCallback callback); void pushMsgToLinks(CalculatedFieldTelemetryMsg msg, List linkedCalculatedFields, TbCallback callback); diff --git a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldResult.java b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldResult.java index c62d5dc6d5..a9c2c532ee 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldResult.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldResult.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.service.cf; +import org.thingsboard.server.common.data.cf.configuration.OutputStrategy; import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.msg.TbMsg; @@ -23,6 +24,8 @@ import java.util.List; public interface CalculatedFieldResult { + OutputStrategy getOutputStrategy(); + TbMsg toTbMsg(EntityId entityId, List cfIds); String stringValue(); diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java index 52393d0ffe..3cec856746 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java @@ -17,6 +17,7 @@ package org.thingsboard.server.service.cf; import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; +import org.apache.logging.log4j.util.TriConsumer; import org.springframework.stereotype.Service; import org.thingsboard.server.actors.calculatedField.CalculatedFieldTelemetryMsg; import org.thingsboard.server.actors.calculatedField.MultipleTbCallback; @@ -47,6 +48,7 @@ import org.thingsboard.server.queue.util.TbRuleEngineComponent; import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId; import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; +import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; import java.util.ArrayList; import java.util.Collections; @@ -72,8 +74,9 @@ public class DefaultCalculatedFieldProcessingService extends AbstractCalculatedF RelationService relationService, OwnerService ownerService, TbClusterService clusterService, + TelemetrySubscriptionService tsSubService, PartitionService partitionService) { - super(attributesService, timeseriesService, apiLimitService, relationService, ownerService); + super(attributesService, timeseriesService, tsSubService, apiLimitService, relationService, ownerService); this.clusterService = clusterService; this.partitionService = partitionService; } @@ -111,27 +114,51 @@ public class DefaultCalculatedFieldProcessingService extends AbstractCalculatedF return resolveArgumentFutures(argFutures); } + @Override + public void processResult(TenantId tenantId, EntityId entityId, CalculatedFieldResult result, List cfIds, TbCallback callback) { + switch (result.getOutputStrategy().getType()) { + case SKIP_RULE_ENGINE -> saveToDB(tenantId, entityId, result, cfIds, callback); + case PUSH_TO_RULE_ENGINE -> pushMsgToRuleEngine(tenantId, entityId, result, cfIds, callback); + } + } + + @Override + public void saveToDB(TenantId tenantId, EntityId entityId, CalculatedFieldResult result, List cfIds, TbCallback callback) { + if (result instanceof TelemetryCalculatedFieldResult telemetryResult) { + saveTelemetryResult(tenantId, entityId, telemetryResult, cfIds, callback); + return; + } + if (result instanceof PropagationCalculatedFieldResult propagationResult) { + handlePropagationResults(propagationResult, callback, + (entity, res, cb) -> saveTelemetryResult(tenantId, entityId, res, cfIds, cb)); + } + } + @Override public void pushMsgToRuleEngine(TenantId tenantId, EntityId entityId, CalculatedFieldResult result, List cfIds, TbCallback callback) { - if (!(result instanceof PropagationCalculatedFieldResult propagationCalculatedFieldResult)) { - TbMsg msg = result.toTbMsg(entityId, cfIds); - sendMsgToRuleEngine(tenantId, entityId, callback, msg); + if (result instanceof PropagationCalculatedFieldResult propagationResult) { + handlePropagationResults(propagationResult, callback, + (entity, res, cb) -> sendMsgToRuleEngine(tenantId, entityId, cb, res.toTbMsg(entity, cfIds))); return; } - List propagationEntityIds = propagationCalculatedFieldResult.getPropagationEntityIds(); + + sendMsgToRuleEngine(tenantId, entityId, callback, result.toTbMsg(entityId, cfIds)); + } + + private void handlePropagationResults(PropagationCalculatedFieldResult propagationResult, TbCallback callback, + TriConsumer telemetryResultHandler) { + List propagationEntityIds = propagationResult.getPropagationEntityIds(); if (propagationEntityIds.isEmpty()) { callback.onSuccess(); } if (propagationEntityIds.size() == 1) { EntityId propagationEntityId = propagationEntityIds.get(0); - TbMsg msg = result.toTbMsg(propagationEntityId, cfIds); - sendMsgToRuleEngine(tenantId, propagationEntityId, callback, msg); + telemetryResultHandler.accept(propagationEntityId, propagationResult.getResult(), callback); return; } MultipleTbCallback multipleTbCallback = new MultipleTbCallback(propagationEntityIds.size(), callback); for (var propagationEntityId : propagationEntityIds) { - TbMsg msg = result.toTbMsg(propagationEntityId, cfIds); - sendMsgToRuleEngine(tenantId, propagationEntityId, multipleTbCallback, msg); + telemetryResultHandler.accept(propagationEntityId, propagationResult.getResult(), multipleTbCallback); } } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/PropagationCalculatedFieldResult.java b/application/src/main/java/org/thingsboard/server/service/cf/PropagationCalculatedFieldResult.java index 780fd220a7..38e1464fb3 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/PropagationCalculatedFieldResult.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/PropagationCalculatedFieldResult.java @@ -17,6 +17,7 @@ package org.thingsboard.server.service.cf; import lombok.Builder; import lombok.Data; +import org.thingsboard.server.common.data.cf.configuration.OutputStrategy; import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.util.CollectionsUtil; @@ -31,6 +32,11 @@ public final class PropagationCalculatedFieldResult implements CalculatedFieldRe private final List propagationEntityIds; private final TelemetryCalculatedFieldResult result; + @Override + public OutputStrategy getOutputStrategy() { + return result.getOutputStrategy(); + } + @Override public TbMsg toTbMsg(EntityId entityId, List cfIds) { return result.toTbMsg(entityId, cfIds); diff --git a/application/src/main/java/org/thingsboard/server/service/cf/TelemetryCalculatedFieldResult.java b/application/src/main/java/org/thingsboard/server/service/cf/TelemetryCalculatedFieldResult.java index 1ad666eac5..69c996c3cb 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/TelemetryCalculatedFieldResult.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/TelemetryCalculatedFieldResult.java @@ -19,6 +19,7 @@ import com.fasterxml.jackson.databind.JsonNode; import lombok.Builder; import lombok.Data; import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.cf.configuration.OutputStrategy; import org.thingsboard.server.common.data.cf.configuration.OutputType; import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.EntityId; @@ -37,6 +38,7 @@ public final class TelemetryCalculatedFieldResult implements CalculatedFieldResu private final OutputType type; private final AttributeScope scope; + private final OutputStrategy outputStrategy; private final JsonNode result; @Override diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldState.java index c52c01549f..7a395284b3 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldState.java @@ -52,6 +52,7 @@ public class ScriptCalculatedFieldState extends BaseCalculatedFieldState { Output output = ctx.getOutput(); return Futures.transform(resultFuture, result -> TelemetryCalculatedFieldResult.builder() + .outputStrategy(output.getStrategy()) .type(output.getType()) .scope(output.getScope()) .result(JacksonUtil.valueToTree(result)) diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java index c3d8c3e63b..462c97aa02 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java @@ -56,6 +56,7 @@ public class SimpleCalculatedFieldState extends BaseCalculatedFieldState { JsonNode outputResult = createResultJson(ctx.isUseLatestTs(), output.getName(), result); return Futures.immediateFuture(TelemetryCalculatedFieldResult.builder() + .outputStrategy(output.getStrategy()) .type(output.getType()) .scope(output.getScope()) .result(outputResult) diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingCalculatedFieldState.java index 51110df2bb..b3ea94e62c 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingCalculatedFieldState.java @@ -130,6 +130,7 @@ public class GeofencingCalculatedFieldState extends BaseCalculatedFieldState { OutputType outputType = ctx.getOutput().getType(); var result = TelemetryCalculatedFieldResult.builder() + .outputStrategy(ctx.getOutput().getStrategy()) .type(outputType) .scope(ctx.getOutput().getScope()) .result(toResultNode(outputType, valuesNode)) diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationCalculatedFieldState.java index 01e9a73de8..fbb8d64581 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationCalculatedFieldState.java @@ -91,6 +91,7 @@ public class PropagationCalculatedFieldState extends ScriptCalculatedFieldState Output output = ctx.getOutput(); TelemetryCalculatedFieldResult.TelemetryCalculatedFieldResultBuilder telemetryCfBuilder = TelemetryCalculatedFieldResult.builder() + .outputStrategy(output.getStrategy()) .type(output.getType()) .scope(output.getScope()); ObjectNode valuesNode = JacksonUtil.newObjectNode(); diff --git a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java index 209a2da6f1..b3f75a1919 100644 --- a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java @@ -35,12 +35,15 @@ import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentType; import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.Output; +import org.thingsboard.server.common.data.cf.configuration.OutputStrategy; import org.thingsboard.server.common.data.cf.configuration.OutputType; import org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration; import org.thingsboard.server.common.data.cf.configuration.ScriptCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.SkipRuleEngineOutputStrategy; +import org.thingsboard.server.common.data.cf.configuration.TimeSeriesSkipRuleEngineOutputStrategy; import org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates; import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.geofencing.ZoneGroupConfiguration; @@ -1162,6 +1165,48 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes }); } + @Test + public void testSimpleCalculatedFieldWhenSkipRuleEngineOutputProcessing() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + + postTelemetry(testDevice.getId(), "{\"temperature\":24.5}"); + + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setEntityId(testDevice.getId()); + calculatedField.setType(CalculatedFieldType.SIMPLE); + calculatedField.setName("C to F"); + calculatedField.setDebugSettings(DebugSettings.all()); + + SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); + + Argument argument = new Argument(); + ReferencedEntityKey refEntityKey = new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null); + argument.setRefEntityKey(refEntityKey); + config.setArguments(Map.of("T", argument)); + config.setExpression("(T * 9/5) + 32"); + + Output output = new Output(); + output.setName("fahrenheitTemp"); + output.setType(OutputType.TIME_SERIES); + output.setDecimalsByDefault(1); + output.setStrategy(new TimeSeriesSkipRuleEngineOutputStrategy(1000L)); + + config.setOutput(output); + + config.setUseLatestTs(true); + + calculatedField.setConfiguration(config); + + CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); + + await().alias("create CF -> perform initial calculation").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode fahrenheitTemp = getLatestTelemetry(testDevice.getId(), "fahrenheitTemp"); + assertThat(fahrenheitTemp).isNotNull(); + assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").asText()).isEqualTo("76.1"); + }); + } private ObjectNode getLatestTelemetry(EntityId entityId, String... keys) throws Exception { return doGetAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + "/values/timeseries?keys=" + String.join(",", keys), ObjectNode.class); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributeSkipRuleEngineOutputStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributeSkipRuleEngineOutputStrategy.java new file mode 100644 index 0000000000..b796042de3 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributeSkipRuleEngineOutputStrategy.java @@ -0,0 +1,29 @@ +/** + * Copyright © 2016-2025 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.cf.configuration; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class AttributeSkipRuleEngineOutputStrategy extends SkipRuleEngineOutputStrategy { + + private boolean updateAttributesOnlyOnValueChange; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Output.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Output.java index f2b4948837..1821db2760 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Output.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Output.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data.cf.configuration; import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonTypeInfo; import lombok.Data; import org.thingsboard.server.common.data.AttributeScope; @@ -28,4 +29,11 @@ public class Output { private AttributeScope scope; private Integer decimalsByDefault; + @JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.EXTERNAL_PROPERTY, + property = "type" + ) + private OutputStrategy strategy; + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/OutputStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/OutputStrategy.java new file mode 100644 index 0000000000..b4b71103e8 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/OutputStrategy.java @@ -0,0 +1,37 @@ +/** + * Copyright © 2016-2025 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.cf.configuration; + + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "type" +) +@JsonSubTypes({ + @JsonSubTypes.Type(value = SkipRuleEngineOutputStrategy.class, name = "SKIP_RULE_ENGINE"), + @JsonSubTypes.Type(value = PushToRuleEngineOutputStrategy.class, name = "PUSH_TO_RULE_ENGINE") +}) +@JsonIgnoreProperties(ignoreUnknown = true) +public interface OutputStrategy { + + OutputStrategyType getType(); + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/OutputStrategyType.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/OutputStrategyType.java new file mode 100644 index 0000000000..d4eef18d61 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/OutputStrategyType.java @@ -0,0 +1,22 @@ +/** + * Copyright © 2016-2025 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.cf.configuration; + +public enum OutputStrategyType { + + SKIP_RULE_ENGINE, PUSH_TO_RULE_ENGINE + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/PushToRuleEngineOutputStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/PushToRuleEngineOutputStrategy.java new file mode 100644 index 0000000000..adeab6c35d --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/PushToRuleEngineOutputStrategy.java @@ -0,0 +1,25 @@ +/** + * Copyright © 2016-2025 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.cf.configuration; + +public class PushToRuleEngineOutputStrategy implements OutputStrategy { + + @Override + public OutputStrategyType getType() { + return OutputStrategyType.PUSH_TO_RULE_ENGINE; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/SkipRuleEngineOutputStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/SkipRuleEngineOutputStrategy.java new file mode 100644 index 0000000000..dfc2873ccb --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/SkipRuleEngineOutputStrategy.java @@ -0,0 +1,37 @@ +/** + * Copyright © 2016-2025 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.cf.configuration; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.EXTERNAL_PROPERTY, + property = "type" +) +@JsonSubTypes({ + @JsonSubTypes.Type(value = AttributeSkipRuleEngineOutputStrategy.class, name = "ATTRIBUTES"), + @JsonSubTypes.Type(value = TimeSeriesSkipRuleEngineOutputStrategy.class, name = "TIME_SERIES") +}) +public abstract class SkipRuleEngineOutputStrategy implements OutputStrategy { + + @Override + public OutputStrategyType getType() { + return OutputStrategyType.SKIP_RULE_ENGINE; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesSkipRuleEngineOutputStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesSkipRuleEngineOutputStrategy.java new file mode 100644 index 0000000000..27cc561035 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesSkipRuleEngineOutputStrategy.java @@ -0,0 +1,29 @@ +/** + * Copyright © 2016-2025 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.cf.configuration; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class TimeSeriesSkipRuleEngineOutputStrategy extends SkipRuleEngineOutputStrategy { + + private long ttl; + +} From 76606f63ba1036b5577dd9acdd60a12c8b0b5a23 Mon Sep 17 00:00:00 2001 From: IrynaMatveieva Date: Mon, 3 Nov 2025 12:14:44 +0200 Subject: [PATCH 20/56] added upgrade script --- .../main/data/upgrade/basic/schema_update.sql | 19 +++++ ...tractCalculatedFieldProcessingService.java | 85 ++++++++++++++++--- ...faultCalculatedFieldProcessingService.java | 2 +- .../cf/CalculatedFieldIntegrationTest.java | 2 +- ...AttributeSkipRuleEngineOutputStrategy.java | 4 + ...imeSeriesSkipRuleEngineOutputStrategy.java | 5 ++ 6 files changed, 101 insertions(+), 16 deletions(-) diff --git a/application/src/main/data/upgrade/basic/schema_update.sql b/application/src/main/data/upgrade/basic/schema_update.sql index fe79fce3a2..cc4fbaad14 100644 --- a/application/src/main/data/upgrade/basic/schema_update.sql +++ b/application/src/main/data/upgrade/basic/schema_update.sql @@ -69,3 +69,22 @@ ALTER TABLE calculated_field DROP CONSTRAINT IF EXISTS calculated_field_unq_key; ALTER TABLE calculated_field ADD CONSTRAINT calculated_field_unq_key UNIQUE (entity_id, type, name); -- CALCULATED FIELD UNIQUE CONSTRAINT UPDATE END + +-- CALCULATED FIELD OUTPUT STRATEGY UPGRADE START + +UPDATE calculated_field +SET configuration = jsonb_set( + configuration::jsonb, + '{output}', + (configuration::jsonb -> 'output') + || jsonb_build_object( + 'strategy', + jsonb_build_object( + 'type', 'PUSH_TO_RULE_ENGINE' + ) + ), + false + ) +WHERE (configuration::jsonb -> 'output' -> 'strategy') IS NULL; + +-- CALCULATED FIELD OUTPUT STRATEGY UPGRADE END diff --git a/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java b/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java index 455093a116..f9c5d6db78 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java @@ -27,12 +27,15 @@ import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import lombok.Data; import lombok.extern.slf4j.Slf4j; +import org.thingsboard.common.util.DonAsynchron; import org.thingsboard.common.util.ThingsBoardExecutors; import org.thingsboard.rule.engine.api.AttributesSaveRequest; +import org.thingsboard.rule.engine.api.AttributesSaveRequest.Strategy; import org.thingsboard.rule.engine.api.TimeseriesSaveRequest; import org.thingsboard.server.common.adaptor.JsonConverter; import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentType; +import org.thingsboard.server.common.data.cf.configuration.AttributeSkipRuleEngineOutputStrategy; import org.thingsboard.server.common.data.cf.configuration.OutputType; import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration; import org.thingsboard.server.common.data.cf.configuration.TimeSeriesSkipRuleEngineOutputStrategy; @@ -71,6 +74,7 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.concurrent.ExecutionException; +import java.util.function.Function; import java.util.stream.Collectors; import static org.thingsboard.server.common.data.cf.CalculatedFieldType.PROPAGATION; @@ -344,8 +348,8 @@ public abstract class AbstractCalculatedFieldProcessingService { SettableFuture future = SettableFuture.create(); switch (type) { - case ATTRIBUTES -> saveAttributes(tenantId, entityId, jsonResult, cfIds, future); - case TIME_SERIES -> saveTimeSeries(tenantId, entityId, jsonResult, ((TimeSeriesSkipRuleEngineOutputStrategy) cfResult.getOutputStrategy()).getTtl(), cfIds, System.currentTimeMillis(), TimeseriesSaveRequest.Strategy.PROCESS_ALL, future); + case ATTRIBUTES -> saveAttributes(tenantId, entityId, cfResult, cfIds, future); + case TIME_SERIES -> saveTimeSeries(tenantId, entityId, cfResult, cfIds, System.currentTimeMillis(), future); } if (log.isTraceEnabled()) { @@ -365,20 +369,54 @@ public abstract class AbstractCalculatedFieldProcessingService { } } - private void saveAttributes(TenantId tenantId, EntityId entityId, JsonElement jsonResult, List cfIds, SettableFuture future) { + private void saveAttributes(TenantId tenantId, EntityId entityId, TelemetryCalculatedFieldResult cfResult, List cfIds, SettableFuture future) { + if (!(cfResult.getOutputStrategy() instanceof AttributeSkipRuleEngineOutputStrategy outputStrategy)) { + return; + } + JsonElement jsonResult = JsonParser.parseString(Objects.requireNonNull(cfResult.stringValue())); + + AttributesSaveRequest.Strategy strategy = new Strategy(outputStrategy.isSaveAttribute(), outputStrategy.isSendWsUpdate(), outputStrategy.isProcessCfs()); List attributeKvEntries = JsonConverter.convertToAttributes(jsonResult); - tsSubService.saveAttributesInternal(AttributesSaveRequest.builder() - .tenantId(tenantId) - .entityId(entityId) - .entries(attributeKvEntries) - .strategy(AttributesSaveRequest.Strategy.PROCESS_ALL) - .previousCalculatedFieldIds(cfIds) - .future(future) - .build() - ); + + if (!outputStrategy.isUpdateAttributesOnlyOnValueChange()) { + tsSubService.saveAttributesInternal(AttributesSaveRequest.builder() + .tenantId(tenantId) + .entityId(entityId) + .entries(attributeKvEntries) + .strategy(strategy) + .previousCalculatedFieldIds(cfIds) + .future(future) + .build() + ); + return; + } + + List keys = attributeKvEntries.stream().map(KvEntry::getKey).collect(Collectors.toList()); + + ListenableFuture> findFuture = attributesService.find(tenantId, entityId, cfResult.getScope(), keys); + + DonAsynchron.withCallback(findFuture, + existingAttributes -> { + List attributesChanged = filterChangedAttr(existingAttributes, attributeKvEntries); + tsSubService.saveAttributesInternal(AttributesSaveRequest.builder() + .tenantId(tenantId) + .entityId(entityId) + .entries(attributesChanged) + .strategy(strategy) + .previousCalculatedFieldIds(cfIds) + .future(future) + .build() + ); + }, + future::setException, + MoreExecutors.directExecutor()); } - private void saveTimeSeries(TenantId tenantId, EntityId entityId, JsonElement jsonResult, Long ttl, List cfIds, long ts, TimeseriesSaveRequest.Strategy strategy, SettableFuture future) { + private void saveTimeSeries(TenantId tenantId, EntityId entityId, TelemetryCalculatedFieldResult cfResult, List cfIds, long ts, SettableFuture future) { + if (!(cfResult.getOutputStrategy() instanceof TimeSeriesSkipRuleEngineOutputStrategy outputStrategy)) { + return; + } + JsonElement jsonResult = JsonParser.parseString(Objects.requireNonNull(cfResult.stringValue())); Map> tsKvMap = JsonConverter.convertToTelemetry(jsonResult, ts); List tsEntries = new ArrayList<>(); for (Map.Entry> tsKvEntry : tsKvMap.entrySet()) { @@ -386,15 +424,34 @@ public abstract class AbstractCalculatedFieldProcessingService { tsEntries.add(new BasicTsKvEntry(tsKvEntry.getKey(), kvEntry)); } } + TimeseriesSaveRequest.Strategy strategy = new TimeseriesSaveRequest.Strategy(outputStrategy.isSaveTimeSeries(), outputStrategy.isSaveLatest(), outputStrategy.isSendWsUpdate(), outputStrategy.isProcessCfs()); tsSubService.saveTimeseriesInternal(TimeseriesSaveRequest.builder() .tenantId(tenantId) .entityId(entityId) .entries(tsEntries) - .ttl(ttl) + .ttl(outputStrategy.getTtl()) .strategy(strategy) .previousCalculatedFieldIds(cfIds) .future(future) .build()); } + private List filterChangedAttr(List existingAttributes, List newAttributes) { + if (existingAttributes == null || existingAttributes.isEmpty()) { + return newAttributes; + } + + Map currentAttrMap = existingAttributes.stream() + .collect(Collectors.toMap(AttributeKvEntry::getKey, Function.identity(), (existing, replacement) -> existing)); + + return newAttributes.stream() + .filter(item -> { + AttributeKvEntry cacheAttr = currentAttrMap.get(item.getKey()); + return cacheAttr == null + || !Objects.equals(item.getValue(), cacheAttr.getValue()) //JSON and String can be equals by value, but different by type + || !Objects.equals(item.getDataType(), cacheAttr.getDataType()); + }) + .collect(Collectors.toList()); + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java index 3cec856746..a6058c7dcb 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java @@ -17,7 +17,7 @@ package org.thingsboard.server.service.cf; import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; -import org.apache.logging.log4j.util.TriConsumer; +import org.apache.commons.lang3.function.TriConsumer; import org.springframework.stereotype.Service; import org.thingsboard.server.actors.calculatedField.CalculatedFieldTelemetryMsg; import org.thingsboard.server.actors.calculatedField.MultipleTbCallback; diff --git a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java index 874d5c8cd6..6a76a2fb99 100644 --- a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java @@ -1235,7 +1235,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes output.setName("fahrenheitTemp"); output.setType(OutputType.TIME_SERIES); output.setDecimalsByDefault(1); - output.setStrategy(new TimeSeriesSkipRuleEngineOutputStrategy(1000L)); + output.setStrategy(new TimeSeriesSkipRuleEngineOutputStrategy(1000L, true, true, true, true)); config.setOutput(output); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributeSkipRuleEngineOutputStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributeSkipRuleEngineOutputStrategy.java index b796042de3..5d30eb36f9 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributeSkipRuleEngineOutputStrategy.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributeSkipRuleEngineOutputStrategy.java @@ -26,4 +26,8 @@ public class AttributeSkipRuleEngineOutputStrategy extends SkipRuleEngineOutputS private boolean updateAttributesOnlyOnValueChange; + private boolean saveAttribute; + private boolean sendWsUpdate; + private boolean processCfs; + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesSkipRuleEngineOutputStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesSkipRuleEngineOutputStrategy.java index 27cc561035..3165fc5eac 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesSkipRuleEngineOutputStrategy.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesSkipRuleEngineOutputStrategy.java @@ -26,4 +26,9 @@ public class TimeSeriesSkipRuleEngineOutputStrategy extends SkipRuleEngineOutput private long ttl; + private boolean saveTimeSeries; + private boolean saveLatest; + private boolean sendWsUpdate; + private boolean processCfs; + } From 94aa57d8949bf12ab451912662789e59de87f9d3 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Wed, 5 Nov 2025 16:24:03 +0200 Subject: [PATCH 21/56] Permission fix --- .../permission/CustomerUserPermissions.java | 13 +++++++++++++ .../security/permission/TenantAdminPermissions.java | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/CustomerUserPermissions.java b/application/src/main/java/org/thingsboard/server/service/security/permission/CustomerUserPermissions.java index 8c71bab9bf..a126bad2c1 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/permission/CustomerUserPermissions.java +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/CustomerUserPermissions.java @@ -21,10 +21,12 @@ import org.thingsboard.server.common.data.HasCustomerId; import org.thingsboard.server.common.data.HasTenantId; import org.thingsboard.server.common.data.TbResourceInfo; import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.id.ApiKeyId; import org.thingsboard.server.common.data.id.DashboardId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TbResourceId; import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.pat.ApiKeyInfo; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.service.security.model.SecurityUser; @@ -48,6 +50,7 @@ public class CustomerUserPermissions extends AbstractPermissions { put(Resource.ASSET_PROFILE, profilePermissionChecker); put(Resource.TB_RESOURCE, customerResourcePermissionChecker); put(Resource.MOBILE_APP_SETTINGS, new PermissionChecker.GenericPermissionChecker(Operation.READ)); + put(Resource.API_KEY, apiKeysPermissionChecker); } private static final PermissionChecker customerAlarmPermissionChecker = new PermissionChecker() { @@ -202,4 +205,14 @@ public class CustomerUserPermissions extends AbstractPermissions { return user.getTenantId().equals(entity.getTenantId()); } }; + + private static final PermissionChecker apiKeysPermissionChecker = new PermissionChecker() { + + @Override + @SuppressWarnings("unchecked") + public boolean hasPermission(SecurityUser user, Operation operation, ApiKeyId entityId, ApiKeyInfo entity) { + return user.getTenantId().equals(entity.getTenantId()) && user.getId().equals(entity.getUserId()); + } + }; + } diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java b/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java index 61103c140b..6e6ebd5cc0 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java @@ -176,7 +176,7 @@ public class TenantAdminPermissions extends AbstractPermissions { @Override public boolean hasPermission(SecurityUser user, Operation operation, ApiKeyId entityId, ApiKeyInfo entity) { - return user.getId().equals(entity.getUserId()); + return user.getTenantId().equals(entity.getTenantId()); } }; From 6f5bf845d87c4c399d5da0cd9faec9bbfc3cfeb9 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Mon, 10 Nov 2025 12:38:03 +0200 Subject: [PATCH 22/56] Improve text search and sort property --- .../server/controller/ApiKeyController.java | 14 ++++++++++++-- .../server/controller/ControllerConstants.java | 1 + .../server/dao/sql/pat/ApiKeyInfoRepository.java | 4 +++- .../server/dao/sql/pat/JpaApiKeyInfoDao.java | 2 +- 4 files changed, 17 insertions(+), 4 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java b/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java index a6da9409ec..d9df135811 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java +++ b/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java @@ -16,6 +16,7 @@ package org.thingsboard.server.controller; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -47,10 +48,13 @@ import java.util.Optional; import java.util.UUID; import static org.thingsboard.server.controller.ControllerConstants.API_KEY_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.API_KEY_TEXT_SEARCH_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER; import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.USER_ID_PARAM_DESCRIPTION; @RestController @@ -86,9 +90,15 @@ public class ApiKeyController extends BaseController { @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true) - @RequestParam int page) throws ThingsboardException { + @RequestParam int page, + @Parameter(description = API_KEY_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "expirationTime", "description", "enabled"})) + @RequestParam(required = false) String sortProperty, + @Parameter(description = SORT_ORDER_DESCRIPTION, schema = @Schema(allowableValues = {"ASC", "DESC"})) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { SecurityUser securityUser = getCurrentUser(); - PageLink pageLink = createPageLink(pageSize, page, null, null, null); + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); UserId userId = new UserId(toUUID(userIdStr)); accessControlService.checkPermission(securityUser, Resource.API_KEY, Operation.READ); return apiKeyService.findApiKeysByUserId(securityUser.getTenantId(), userId, pageLink); diff --git a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java index dbaee4300c..3c110016e6 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java +++ b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java @@ -92,6 +92,7 @@ public class ControllerConstants { protected static final String RULE_CHAIN_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the rule chain name."; protected static final String DEVICE_PROFILE_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the device profile name."; protected static final String AI_MODEL_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the AI model name, provider and model ID."; + protected static final String API_KEY_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the description."; protected static final String ASSET_PROFILE_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the asset profile name."; protected static final String CUSTOMER_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the customer title."; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyInfoRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyInfoRepository.java index cc15a08fb1..e2cbb2c050 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyInfoRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyInfoRepository.java @@ -26,9 +26,11 @@ import java.util.UUID; public interface ApiKeyInfoRepository extends JpaRepository { - @Query("SELECT ak FROM ApiKeyInfoEntity ak WHERE ak.tenantId = :tenantId AND ak.userId = :userId") + @Query("SELECT ak FROM ApiKeyInfoEntity ak WHERE ak.tenantId = :tenantId AND ak.userId = :userId AND " + + "(:searchText is NULL OR ilike(ak.description, concat('%', :searchText, '%')) = true)") Page findByUserId(@Param("tenantId") UUID tenantId, @Param("userId") UUID userId, + @Param("searchText") String searchText, Pageable pageable); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/pat/JpaApiKeyInfoDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/pat/JpaApiKeyInfoDao.java index b133c42aed..f152f672d4 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/pat/JpaApiKeyInfoDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/pat/JpaApiKeyInfoDao.java @@ -42,7 +42,7 @@ public class JpaApiKeyInfoDao extends JpaAbstractDao findByUserId(TenantId tenantId, UserId userId, PageLink pageLink) { - return DaoUtil.toPageData(apiKeyInfoRepository.findByUserId(tenantId.getId(), userId.getId(), DaoUtil.toPageable(pageLink))); + return DaoUtil.toPageData(apiKeyInfoRepository.findByUserId(tenantId.getId(), userId.getId(), pageLink.getTextSearch(), DaoUtil.toPageable(pageLink))); } @Override From 31ed28a6feaffd9d94a70a429f8ce4326c1c3dfd Mon Sep 17 00:00:00 2001 From: IrynaMatveieva Date: Tue, 11 Nov 2025 09:46:48 +0200 Subject: [PATCH 23/56] restructured output strategies --- .../main/data/upgrade/basic/schema_update.sql | 6 +- ...tractCalculatedFieldProcessingService.java | 84 ++++++++----------- .../cf/AlarmCalculatedFieldResult.java | 7 -- .../cf/CalculatedFieldProcessingService.java | 2 +- .../service/cf/CalculatedFieldResult.java | 3 - ...faultCalculatedFieldProcessingService.java | 17 +++- .../cf/PropagationCalculatedFieldResult.java | 6 -- .../cf/CalculatedFieldIntegrationTest.java | 4 +- ... => AttributeImmediateOutputStrategy.java} | 8 +- ... => AttributeRuleChainOutputStrategy.java} | 22 ++--- ...tegy.java => ImmediateOutputStrategy.java} | 6 +- .../common/data/cf/configuration/Output.java | 7 -- .../data/cf/configuration/OutputStrategy.java | 22 +++-- .../cf/configuration/OutputStrategyType.java | 2 +- .../RuleChainOutputStrategy.java | 25 ++++++ ...=> TimeSeriesImmediateOutputStrategy.java} | 8 +- .../TimeSeriesRuleChainOutputStrategy.java | 29 +++++++ .../engine/telemetry/TbMsgAttributesNode.java | 22 +---- .../engine/telemetry/TbMsgTimeseriesNode.java | 8 +- .../rule/engine/util/TelemetryUtil.java | 60 +++++++++++++ 20 files changed, 209 insertions(+), 139 deletions(-) rename common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/{AttributeSkipRuleEngineOutputStrategy.java => AttributeImmediateOutputStrategy.java} (85%) rename common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/{SkipRuleEngineOutputStrategy.java => AttributeRuleChainOutputStrategy.java} (52%) rename common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/{PushToRuleEngineOutputStrategy.java => ImmediateOutputStrategy.java} (80%) create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RuleChainOutputStrategy.java rename common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/{TimeSeriesSkipRuleEngineOutputStrategy.java => TimeSeriesImmediateOutputStrategy.java} (85%) create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesRuleChainOutputStrategy.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TelemetryUtil.java diff --git a/application/src/main/data/upgrade/basic/schema_update.sql b/application/src/main/data/upgrade/basic/schema_update.sql index cc4fbaad14..565ac50afd 100644 --- a/application/src/main/data/upgrade/basic/schema_update.sql +++ b/application/src/main/data/upgrade/basic/schema_update.sql @@ -80,7 +80,11 @@ SET configuration = jsonb_set( || jsonb_build_object( 'strategy', jsonb_build_object( - 'type', 'PUSH_TO_RULE_ENGINE' + 'type', + CASE (configuration::jsonb -> 'output' ->> 'type') + WHEN 'TIME_SERIES' THEN 'RULE_CHAIN_TIME_SERIES' + WHEN 'ATTRIBUTES' THEN 'RULE_CHAIN_ATTRIBUTES' + END ) ), false diff --git a/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java b/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java index ba3f2d4cc5..5ecbeeb496 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java @@ -36,10 +36,10 @@ import org.thingsboard.server.common.adaptor.JsonConverter; import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentType; -import org.thingsboard.server.common.data.cf.configuration.AttributeSkipRuleEngineOutputStrategy; +import org.thingsboard.server.common.data.cf.configuration.AttributeImmediateOutputStrategy; import org.thingsboard.server.common.data.cf.configuration.OutputType; import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration; -import org.thingsboard.server.common.data.cf.configuration.TimeSeriesSkipRuleEngineOutputStrategy; +import org.thingsboard.server.common.data.cf.configuration.TimeSeriesImmediateOutputStrategy; import org.thingsboard.server.common.data.cf.configuration.aggregation.RelatedEntitiesAggregationCalculatedFieldConfiguration; import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.EntityId; @@ -66,7 +66,6 @@ import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry; import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; -import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -75,10 +74,11 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.concurrent.ExecutionException; -import java.util.function.Function; import java.util.function.Predicate; import java.util.stream.Collectors; +import static org.thingsboard.rule.engine.util.TelemetryUtil.filterChangedAttr; +import static org.thingsboard.rule.engine.util.TelemetryUtil.toTsKvEntryList; import static org.thingsboard.server.common.data.cf.CalculatedFieldType.PROPAGATION; import static org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration.PROPAGATION_CONFIG_ARGUMENT; import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY; @@ -378,60 +378,60 @@ public abstract class AbstractCalculatedFieldProcessingService { } private void saveAttributes(TenantId tenantId, EntityId entityId, TelemetryCalculatedFieldResult cfResult, List cfIds, SettableFuture future) { - if (!(cfResult.getOutputStrategy() instanceof AttributeSkipRuleEngineOutputStrategy outputStrategy)) { + if (!(cfResult.getOutputStrategy() instanceof AttributeImmediateOutputStrategy outputStrategy)) { + future.setException(new IllegalArgumentException("Expected AttributeImmediateOutputStrategy")); return; } JsonElement jsonResult = JsonParser.parseString(Objects.requireNonNull(cfResult.stringValue())); AttributesSaveRequest.Strategy strategy = new Strategy(outputStrategy.isSaveAttribute(), outputStrategy.isSendWsUpdate(), outputStrategy.isProcessCfs()); - List attributeKvEntries = JsonConverter.convertToAttributes(jsonResult); + List newAttributes = JsonConverter.convertToAttributes(jsonResult); if (!outputStrategy.isUpdateAttributesOnlyOnValueChange()) { - tsSubService.saveAttributesInternal(AttributesSaveRequest.builder() - .tenantId(tenantId) - .entityId(entityId) - .entries(attributeKvEntries) - .strategy(strategy) - .previousCalculatedFieldIds(cfIds) - .future(future) - .build() - ); + saveAttributesInternal(tenantId, entityId, cfResult, cfIds, newAttributes, strategy, future); return; } - List keys = attributeKvEntries.stream().map(KvEntry::getKey).collect(Collectors.toList()); - + List keys = newAttributes.stream().map(KvEntry::getKey).collect(Collectors.toList()); ListenableFuture> findFuture = attributesService.find(tenantId, entityId, cfResult.getScope(), keys); DonAsynchron.withCallback(findFuture, existingAttributes -> { - List attributesChanged = filterChangedAttr(existingAttributes, attributeKvEntries); - tsSubService.saveAttributesInternal(AttributesSaveRequest.builder() - .tenantId(tenantId) - .entityId(entityId) - .entries(attributesChanged) - .strategy(strategy) - .previousCalculatedFieldIds(cfIds) - .future(future) - .build() - ); + List changed = filterChangedAttr(existingAttributes, newAttributes); + saveAttributesInternal(tenantId, entityId, cfResult, cfIds, changed, strategy, future); }, future::setException, MoreExecutors.directExecutor()); } + private void saveAttributesInternal(TenantId tenantId, EntityId entityId, + TelemetryCalculatedFieldResult cfResult, + List cfIds, + List entries, + AttributesSaveRequest.Strategy strategy, + SettableFuture future) { + tsSubService.saveAttributesInternal(AttributesSaveRequest.builder() + .tenantId(tenantId) + .entityId(entityId) + .scope(cfResult.getScope()) + .entries(entries) + .strategy(strategy) + .previousCalculatedFieldIds(cfIds) + .future(future) + .build()); + } + private void saveTimeSeries(TenantId tenantId, EntityId entityId, TelemetryCalculatedFieldResult cfResult, List cfIds, long ts, SettableFuture future) { - if (!(cfResult.getOutputStrategy() instanceof TimeSeriesSkipRuleEngineOutputStrategy outputStrategy)) { + if (!(cfResult.getOutputStrategy() instanceof TimeSeriesImmediateOutputStrategy outputStrategy)) { + future.setException(new IllegalArgumentException("Expected TimeSeriesImmediateOutputStrategy")); return; } JsonElement jsonResult = JsonParser.parseString(Objects.requireNonNull(cfResult.stringValue())); Map> tsKvMap = JsonConverter.convertToTelemetry(jsonResult, ts); - List tsEntries = new ArrayList<>(); - for (Map.Entry> tsKvEntry : tsKvMap.entrySet()) { - for (KvEntry kvEntry : tsKvEntry.getValue()) { - tsEntries.add(new BasicTsKvEntry(tsKvEntry.getKey(), kvEntry)); - } + if (tsKvMap.isEmpty()) { + future.setFuture(Futures.immediateFuture(null)); } + List tsEntries = toTsKvEntryList(tsKvMap); TimeseriesSaveRequest.Strategy strategy = new TimeseriesSaveRequest.Strategy(outputStrategy.isSaveTimeSeries(), outputStrategy.isSaveLatest(), outputStrategy.isSendWsUpdate(), outputStrategy.isProcessCfs()); tsSubService.saveTimeseriesInternal(TimeseriesSaveRequest.builder() .tenantId(tenantId) @@ -444,22 +444,4 @@ public abstract class AbstractCalculatedFieldProcessingService { .build()); } - private List filterChangedAttr(List existingAttributes, List newAttributes) { - if (existingAttributes == null || existingAttributes.isEmpty()) { - return newAttributes; - } - - Map currentAttrMap = existingAttributes.stream() - .collect(Collectors.toMap(AttributeKvEntry::getKey, Function.identity(), (existing, replacement) -> existing)); - - return newAttributes.stream() - .filter(item -> { - AttributeKvEntry cacheAttr = currentAttrMap.get(item.getKey()); - return cacheAttr == null - || !Objects.equals(item.getValue(), cacheAttr.getValue()) //JSON and String can be equals by value, but different by type - || !Objects.equals(item.getDataType(), cacheAttr.getDataType()); - }) - .collect(Collectors.toList()); - } - } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/AlarmCalculatedFieldResult.java b/application/src/main/java/org/thingsboard/server/service/cf/AlarmCalculatedFieldResult.java index 3191b84193..498a215e17 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/AlarmCalculatedFieldResult.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/AlarmCalculatedFieldResult.java @@ -21,8 +21,6 @@ import lombok.RequiredArgsConstructor; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.action.TbAlarmResult; import org.thingsboard.server.common.data.DataConstants; -import org.thingsboard.server.common.data.cf.configuration.OutputStrategy; -import org.thingsboard.server.common.data.cf.configuration.PushToRuleEngineOutputStrategy; import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.msg.TbMsgType; @@ -38,11 +36,6 @@ public class AlarmCalculatedFieldResult implements CalculatedFieldResult { private final TbAlarmResult alarmResult; - @Override - public OutputStrategy getOutputStrategy() { - return new PushToRuleEngineOutputStrategy(); - } - @Override public TbMsg toTbMsg(EntityId entityId, List cfIds) { TbMsgType msgType; diff --git a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldProcessingService.java b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldProcessingService.java index 5473f3f4a9..0475cb1b66 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldProcessingService.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldProcessingService.java @@ -37,7 +37,7 @@ public interface CalculatedFieldProcessingService { Map fetchArgsFromDb(TenantId tenantId, EntityId entityId, Map arguments); - void saveToDB(TenantId tenantId, EntityId entityId, CalculatedFieldResult result, List cfIds, TbCallback callback); + void processImmediately(TenantId tenantId, EntityId entityId, CalculatedFieldResult result, List cfIds, TbCallback callback); void processResult(TenantId tenantId, EntityId entityId, CalculatedFieldResult result, List cfIds, TbCallback callback); diff --git a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldResult.java b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldResult.java index a9c2c532ee..c62d5dc6d5 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldResult.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldResult.java @@ -15,7 +15,6 @@ */ package org.thingsboard.server.service.cf; -import org.thingsboard.server.common.data.cf.configuration.OutputStrategy; import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.msg.TbMsg; @@ -24,8 +23,6 @@ import java.util.List; public interface CalculatedFieldResult { - OutputStrategy getOutputStrategy(); - TbMsg toTbMsg(EntityId entityId, List cfIds); String stringValue(); diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java index a6058c7dcb..09fdf62ddf 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java @@ -116,14 +116,20 @@ public class DefaultCalculatedFieldProcessingService extends AbstractCalculatedF @Override public void processResult(TenantId tenantId, EntityId entityId, CalculatedFieldResult result, List cfIds, TbCallback callback) { - switch (result.getOutputStrategy().getType()) { - case SKIP_RULE_ENGINE -> saveToDB(tenantId, entityId, result, cfIds, callback); - case PUSH_TO_RULE_ENGINE -> pushMsgToRuleEngine(tenantId, entityId, result, cfIds, callback); + if (result instanceof AlarmCalculatedFieldResult) { + sendMsgToRuleEngine(tenantId, entityId, callback, result.toTbMsg(entityId, cfIds)); + return; + } + TelemetryCalculatedFieldResult telemetryResult = result instanceof TelemetryCalculatedFieldResult telemetryRes + ? telemetryRes : ((PropagationCalculatedFieldResult) result).getResult(); + switch (telemetryResult.getOutputStrategy().getStrategyType()) { + case IMMEDIATE -> processImmediately(tenantId, entityId, result, cfIds, callback); + case RULE_CHAIN -> pushMsgToRuleEngine(tenantId, entityId, result, cfIds, callback); } } @Override - public void saveToDB(TenantId tenantId, EntityId entityId, CalculatedFieldResult result, List cfIds, TbCallback callback) { + public void processImmediately(TenantId tenantId, EntityId entityId, CalculatedFieldResult result, List cfIds, TbCallback callback) { if (result instanceof TelemetryCalculatedFieldResult telemetryResult) { saveTelemetryResult(tenantId, entityId, telemetryResult, cfIds, callback); return; @@ -131,7 +137,9 @@ public class DefaultCalculatedFieldProcessingService extends AbstractCalculatedF if (result instanceof PropagationCalculatedFieldResult propagationResult) { handlePropagationResults(propagationResult, callback, (entity, res, cb) -> saveTelemetryResult(tenantId, entityId, res, cfIds, cb)); + return; } + callback.onSuccess(); } @Override @@ -150,6 +158,7 @@ public class DefaultCalculatedFieldProcessingService extends AbstractCalculatedF List propagationEntityIds = propagationResult.getPropagationEntityIds(); if (propagationEntityIds.isEmpty()) { callback.onSuccess(); + return; } if (propagationEntityIds.size() == 1) { EntityId propagationEntityId = propagationEntityIds.get(0); diff --git a/application/src/main/java/org/thingsboard/server/service/cf/PropagationCalculatedFieldResult.java b/application/src/main/java/org/thingsboard/server/service/cf/PropagationCalculatedFieldResult.java index 38e1464fb3..780fd220a7 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/PropagationCalculatedFieldResult.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/PropagationCalculatedFieldResult.java @@ -17,7 +17,6 @@ package org.thingsboard.server.service.cf; import lombok.Builder; import lombok.Data; -import org.thingsboard.server.common.data.cf.configuration.OutputStrategy; import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.util.CollectionsUtil; @@ -32,11 +31,6 @@ public final class PropagationCalculatedFieldResult implements CalculatedFieldRe private final List propagationEntityIds; private final TelemetryCalculatedFieldResult result; - @Override - public OutputStrategy getOutputStrategy() { - return result.getOutputStrategy(); - } - @Override public TbMsg toTbMsg(EntityId entityId, List cfIds) { return result.toTbMsg(entityId, cfIds); diff --git a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java index 6a76a2fb99..8593ea8ae5 100644 --- a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java @@ -41,7 +41,7 @@ import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration; import org.thingsboard.server.common.data.cf.configuration.ScriptCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; -import org.thingsboard.server.common.data.cf.configuration.TimeSeriesSkipRuleEngineOutputStrategy; +import org.thingsboard.server.common.data.cf.configuration.TimeSeriesImmediateOutputStrategy; import org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates; import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.geofencing.ZoneGroupConfiguration; @@ -1235,7 +1235,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes output.setName("fahrenheitTemp"); output.setType(OutputType.TIME_SERIES); output.setDecimalsByDefault(1); - output.setStrategy(new TimeSeriesSkipRuleEngineOutputStrategy(1000L, true, true, true, true)); + output.setStrategy(new TimeSeriesImmediateOutputStrategy(1000L, true, true, true, true)); config.setOutput(output); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributeSkipRuleEngineOutputStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributeImmediateOutputStrategy.java similarity index 85% rename from common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributeSkipRuleEngineOutputStrategy.java rename to common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributeImmediateOutputStrategy.java index 5d30eb36f9..5ca28d0ee4 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributeSkipRuleEngineOutputStrategy.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributeImmediateOutputStrategy.java @@ -20,9 +20,9 @@ import lombok.Data; import lombok.NoArgsConstructor; @Data -@NoArgsConstructor @AllArgsConstructor -public class AttributeSkipRuleEngineOutputStrategy extends SkipRuleEngineOutputStrategy { +@NoArgsConstructor +public class AttributeImmediateOutputStrategy extends ImmediateOutputStrategy { private boolean updateAttributesOnlyOnValueChange; @@ -30,4 +30,8 @@ public class AttributeSkipRuleEngineOutputStrategy extends SkipRuleEngineOutputS private boolean sendWsUpdate; private boolean processCfs; + @Override + public String getType() { + return "IMMEDIATE_ATTRIBUTES"; + } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/SkipRuleEngineOutputStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributeRuleChainOutputStrategy.java similarity index 52% rename from common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/SkipRuleEngineOutputStrategy.java rename to common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributeRuleChainOutputStrategy.java index dfc2873ccb..adefc06964 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/SkipRuleEngineOutputStrategy.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributeRuleChainOutputStrategy.java @@ -15,23 +15,15 @@ */ package org.thingsboard.server.common.data.cf.configuration; -import com.fasterxml.jackson.annotation.JsonSubTypes; -import com.fasterxml.jackson.annotation.JsonTypeInfo; +import lombok.Data; +import lombok.NoArgsConstructor; -@JsonTypeInfo( - use = JsonTypeInfo.Id.NAME, - include = JsonTypeInfo.As.EXTERNAL_PROPERTY, - property = "type" -) -@JsonSubTypes({ - @JsonSubTypes.Type(value = AttributeSkipRuleEngineOutputStrategy.class, name = "ATTRIBUTES"), - @JsonSubTypes.Type(value = TimeSeriesSkipRuleEngineOutputStrategy.class, name = "TIME_SERIES") -}) -public abstract class SkipRuleEngineOutputStrategy implements OutputStrategy { +@Data +@NoArgsConstructor +public class AttributeRuleChainOutputStrategy extends RuleChainOutputStrategy { @Override - public OutputStrategyType getType() { - return OutputStrategyType.SKIP_RULE_ENGINE; + public String getType() { + return "RULE_CHAIN_ATTRIBUTES"; } - } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/PushToRuleEngineOutputStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ImmediateOutputStrategy.java similarity index 80% rename from common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/PushToRuleEngineOutputStrategy.java rename to common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ImmediateOutputStrategy.java index adeab6c35d..170fa5bb9c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/PushToRuleEngineOutputStrategy.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ImmediateOutputStrategy.java @@ -15,11 +15,11 @@ */ package org.thingsboard.server.common.data.cf.configuration; -public class PushToRuleEngineOutputStrategy implements OutputStrategy { +public abstract class ImmediateOutputStrategy implements OutputStrategy { @Override - public OutputStrategyType getType() { - return OutputStrategyType.PUSH_TO_RULE_ENGINE; + public OutputStrategyType getStrategyType() { + return OutputStrategyType.IMMEDIATE; } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Output.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Output.java index 1821db2760..d2e0ac1ca9 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Output.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Output.java @@ -16,7 +16,6 @@ package org.thingsboard.server.common.data.cf.configuration; import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.annotation.JsonTypeInfo; import lombok.Data; import org.thingsboard.server.common.data.AttributeScope; @@ -28,12 +27,6 @@ public class Output { private OutputType type; private AttributeScope scope; private Integer decimalsByDefault; - - @JsonTypeInfo( - use = JsonTypeInfo.Id.NAME, - include = JsonTypeInfo.As.EXTERNAL_PROPERTY, - property = "type" - ) private OutputStrategy strategy; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/OutputStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/OutputStrategy.java index b4b71103e8..8f488602fe 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/OutputStrategy.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/OutputStrategy.java @@ -15,23 +15,31 @@ */ package org.thingsboard.server.common.data.cf.configuration; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonTypeInfo.As; @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, - include = JsonTypeInfo.As.PROPERTY, + include = As.PROPERTY, property = "type" ) @JsonSubTypes({ - @JsonSubTypes.Type(value = SkipRuleEngineOutputStrategy.class, name = "SKIP_RULE_ENGINE"), - @JsonSubTypes.Type(value = PushToRuleEngineOutputStrategy.class, name = "PUSH_TO_RULE_ENGINE") + @Type(value = AttributeImmediateOutputStrategy.class, name = "IMMEDIATE_ATTRIBUTES"), + @Type(value = TimeSeriesImmediateOutputStrategy.class, name = "IMMEDIATE_TIME_SERIES"), + + @Type(value = AttributeRuleChainOutputStrategy.class, name = "RULE_CHAIN_ATTRIBUTES"), + @Type(value = TimeSeriesRuleChainOutputStrategy.class, name = "RULE_CHAIN_TIME_SERIES") + }) -@JsonIgnoreProperties(ignoreUnknown = true) public interface OutputStrategy { - OutputStrategyType getType(); + @JsonIgnore + OutputStrategyType getStrategyType(); + + @JsonIgnore + String getType(); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/OutputStrategyType.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/OutputStrategyType.java index d4eef18d61..4f5234acb5 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/OutputStrategyType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/OutputStrategyType.java @@ -17,6 +17,6 @@ package org.thingsboard.server.common.data.cf.configuration; public enum OutputStrategyType { - SKIP_RULE_ENGINE, PUSH_TO_RULE_ENGINE + IMMEDIATE, RULE_CHAIN } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RuleChainOutputStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RuleChainOutputStrategy.java new file mode 100644 index 0000000000..0d601d22fc --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RuleChainOutputStrategy.java @@ -0,0 +1,25 @@ +/** + * Copyright © 2016-2025 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.cf.configuration; + +public abstract class RuleChainOutputStrategy implements OutputStrategy { + + @Override + public OutputStrategyType getStrategyType() { + return OutputStrategyType.RULE_CHAIN; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesSkipRuleEngineOutputStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesImmediateOutputStrategy.java similarity index 85% rename from common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesSkipRuleEngineOutputStrategy.java rename to common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesImmediateOutputStrategy.java index 3165fc5eac..60d5f99515 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesSkipRuleEngineOutputStrategy.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesImmediateOutputStrategy.java @@ -20,9 +20,9 @@ import lombok.Data; import lombok.NoArgsConstructor; @Data -@NoArgsConstructor @AllArgsConstructor -public class TimeSeriesSkipRuleEngineOutputStrategy extends SkipRuleEngineOutputStrategy { +@NoArgsConstructor +public class TimeSeriesImmediateOutputStrategy extends ImmediateOutputStrategy { private long ttl; @@ -31,4 +31,8 @@ public class TimeSeriesSkipRuleEngineOutputStrategy extends SkipRuleEngineOutput private boolean sendWsUpdate; private boolean processCfs; + @Override + public String getType() { + return "IMMEDIATE_TIME_SERIES"; + } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesRuleChainOutputStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesRuleChainOutputStrategy.java new file mode 100644 index 0000000000..bb50cc5e8b --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesRuleChainOutputStrategy.java @@ -0,0 +1,29 @@ +/** + * Copyright © 2016-2025 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.cf.configuration; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class TimeSeriesRuleChainOutputStrategy extends RuleChainOutputStrategy { + + @Override + public String getType() { + return "RULE_CHAIN_TIME_SERIES"; + } +} diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNode.java index 533f7d13dd..20aa7993a1 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNode.java @@ -41,16 +41,14 @@ import org.thingsboard.server.common.data.util.TbPair; import org.thingsboard.server.common.msg.TbMsg; import java.util.List; -import java.util.Map; -import java.util.Objects; import java.util.UUID; -import java.util.function.Function; import java.util.stream.Collectors; import static org.thingsboard.rule.engine.telemetry.settings.AttributesProcessingSettings.Advanced; import static org.thingsboard.rule.engine.telemetry.settings.AttributesProcessingSettings.Deduplicate; import static org.thingsboard.rule.engine.telemetry.settings.AttributesProcessingSettings.OnEveryMessage; import static org.thingsboard.rule.engine.telemetry.settings.AttributesProcessingSettings.WebSocketsOnly; +import static org.thingsboard.rule.engine.util.TelemetryUtil.filterChangedAttr; import static org.thingsboard.server.common.data.DataConstants.NOTIFY_DEVICE_METADATA_KEY; import static org.thingsboard.server.common.data.DataConstants.SCOPE; import static org.thingsboard.server.common.data.msg.TbMsgType.POST_ATTRIBUTES_REQUEST; @@ -216,24 +214,6 @@ public class TbMsgAttributesNode implements TbNode { .build()); } - private List filterChangedAttr(List currentAttributes, List newAttributes) { - if (currentAttributes == null || currentAttributes.isEmpty()) { - return newAttributes; - } - - Map currentAttrMap = currentAttributes.stream() - .collect(Collectors.toMap(AttributeKvEntry::getKey, Function.identity(), (existing, replacement) -> existing)); - - return newAttributes.stream() - .filter(item -> { - AttributeKvEntry cacheAttr = currentAttrMap.get(item.getKey()); - return cacheAttr == null - || !Objects.equals(item.getValue(), cacheAttr.getValue()) //JSON and String can be equals by value, but different by type - || !Objects.equals(item.getDataType(), cacheAttr.getDataType()); - }) - .collect(Collectors.toList()); - } - private boolean checkSendNotification(AttributeScope scope) { return config.isSendAttributesUpdatedNotification() && AttributeScope.CLIENT_SCOPE != scope; } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgTimeseriesNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgTimeseriesNode.java index 13dab98c54..32f06b1e00 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgTimeseriesNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgTimeseriesNode.java @@ -49,6 +49,7 @@ import static org.thingsboard.rule.engine.telemetry.settings.TimeseriesProcessin import static org.thingsboard.rule.engine.telemetry.settings.TimeseriesProcessingSettings.Deduplicate; import static org.thingsboard.rule.engine.telemetry.settings.TimeseriesProcessingSettings.OnEveryMessage; import static org.thingsboard.rule.engine.telemetry.settings.TimeseriesProcessingSettings.WebSocketsOnly; +import static org.thingsboard.rule.engine.util.TelemetryUtil.toTsKvEntryList; import static org.thingsboard.server.common.data.msg.TbMsgType.POST_TELEMETRY_REQUEST; @RuleNode( @@ -148,12 +149,7 @@ public class TbMsgTimeseriesNode implements TbNode { ctx.tellFailure(msg, new IllegalArgumentException("Msg body is empty: " + src)); return; } - List tsKvEntryList = new ArrayList<>(); - for (Map.Entry> tsKvEntry : tsKvMap.entrySet()) { - for (KvEntry kvEntry : tsKvEntry.getValue()) { - tsKvEntryList.add(new BasicTsKvEntry(tsKvEntry.getKey(), kvEntry)); - } - } + List tsKvEntryList = toTsKvEntryList(tsKvMap); String ttlValue = msg.getMetaData().getValue("TTL"); long ttl = !StringUtils.isEmpty(ttlValue) ? Long.parseLong(ttlValue) : config.getDefaultTTL(); if (ttl == 0L) { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TelemetryUtil.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TelemetryUtil.java new file mode 100644 index 0000000000..41d6f1ce1d --- /dev/null +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TelemetryUtil.java @@ -0,0 +1,60 @@ +/** + * Copyright © 2016-2025 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.rule.engine.util; + +import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.BasicTsKvEntry; +import org.thingsboard.server.common.data.kv.KvEntry; +import org.thingsboard.server.common.data.kv.TsKvEntry; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; + +public class TelemetryUtil { + + public static List toTsKvEntryList(Map> tsKvMap) { + List tsKvEntryList = new ArrayList<>(); + for (Map.Entry> tsKvEntry : tsKvMap.entrySet()) { + for (KvEntry kvEntry : tsKvEntry.getValue()) { + tsKvEntryList.add(new BasicTsKvEntry(tsKvEntry.getKey(), kvEntry)); + } + } + return tsKvEntryList; + } + + public static List filterChangedAttr(List currentAttributes, List newAttributes) { + if (currentAttributes == null || currentAttributes.isEmpty()) { + return newAttributes; + } + + Map currentAttrMap = currentAttributes.stream() + .collect(Collectors.toMap(AttributeKvEntry::getKey, Function.identity(), (existing, replacement) -> existing)); + + return newAttributes.stream() + .filter(item -> { + AttributeKvEntry cacheAttr = currentAttrMap.get(item.getKey()); + return cacheAttr == null + || !Objects.equals(item.getValue(), cacheAttr.getValue()) //JSON and String can be equals by value, but different by type + || !Objects.equals(item.getDataType(), cacheAttr.getDataType()); + }) + .collect(Collectors.toList()); + } + +} From 256f4c3121a2d4f4acdded5cec7c74e217ca2436 Mon Sep 17 00:00:00 2001 From: IrynaMatveieva Date: Tue, 11 Nov 2025 10:53:49 +0200 Subject: [PATCH 24/56] added checks when no updated attributes --- application/src/main/data/upgrade/basic/schema_update.sql | 4 ++-- .../cf/AbstractCalculatedFieldProcessingService.java | 7 ++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/application/src/main/data/upgrade/basic/schema_update.sql b/application/src/main/data/upgrade/basic/schema_update.sql index 565ac50afd..a7909a358a 100644 --- a/application/src/main/data/upgrade/basic/schema_update.sql +++ b/application/src/main/data/upgrade/basic/schema_update.sql @@ -70,7 +70,7 @@ ALTER TABLE calculated_field ADD CONSTRAINT calculated_field_unq_key UNIQUE (ent -- CALCULATED FIELD UNIQUE CONSTRAINT UPDATE END --- CALCULATED FIELD OUTPUT STRATEGY UPGRADE START +-- CALCULATED FIELD OUTPUT STRATEGY UPDATE START UPDATE calculated_field SET configuration = jsonb_set( @@ -91,4 +91,4 @@ SET configuration = jsonb_set( ) WHERE (configuration::jsonb -> 'output' -> 'strategy') IS NULL; --- CALCULATED FIELD OUTPUT STRATEGY UPGRADE END +-- CALCULATED FIELD OUTPUT STRATEGY UPDATE END diff --git a/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java b/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java index 5ecbeeb496..8cec758f5b 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java @@ -398,6 +398,10 @@ public abstract class AbstractCalculatedFieldProcessingService { DonAsynchron.withCallback(findFuture, existingAttributes -> { List changed = filterChangedAttr(existingAttributes, newAttributes); + if (changed.isEmpty()) { + future.set(null); + return; + } saveAttributesInternal(tenantId, entityId, cfResult, cfIds, changed, strategy, future); }, future::setException, @@ -429,7 +433,8 @@ public abstract class AbstractCalculatedFieldProcessingService { JsonElement jsonResult = JsonParser.parseString(Objects.requireNonNull(cfResult.stringValue())); Map> tsKvMap = JsonConverter.convertToTelemetry(jsonResult, ts); if (tsKvMap.isEmpty()) { - future.setFuture(Futures.immediateFuture(null)); + future.set(null); + return; } List tsEntries = toTsKvEntryList(tsKvMap); TimeseriesSaveRequest.Strategy strategy = new TimeseriesSaveRequest.Strategy(outputStrategy.isSaveTimeSeries(), outputStrategy.isSaveLatest(), outputStrategy.isSendWsUpdate(), outputStrategy.isProcessCfs()); From 5918bbf082750e0c1d27639edd93a25a33c50931 Mon Sep 17 00:00:00 2001 From: IrynaMatveieva Date: Tue, 11 Nov 2025 11:51:36 +0200 Subject: [PATCH 25/56] fixed timeout and removed unnecessary abtract classes --- ...tractCalculatedFieldProcessingService.java | 26 +++++++++---------- ...faultCalculatedFieldProcessingService.java | 12 +++++---- .../AttributeImmediateOutputStrategy.java | 6 ++--- .../AttributeRuleChainOutputStrategy.java | 6 ++--- .../ImmediateOutputStrategy.java | 25 ------------------ .../data/cf/configuration/OutputStrategy.java | 6 +---- .../cf/configuration/OutputStrategyType.java | 6 ++++- .../RuleChainOutputStrategy.java | 25 ------------------ .../TimeSeriesImmediateOutputStrategy.java | 6 ++--- .../TimeSeriesRuleChainOutputStrategy.java | 6 ++--- 10 files changed, 37 insertions(+), 87 deletions(-) delete mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ImmediateOutputStrategy.java delete mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RuleChainOutputStrategy.java diff --git a/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java b/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java index 724aaa1804..6d16e11a17 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java @@ -361,21 +361,19 @@ public abstract class AbstractCalculatedFieldProcessingService { case TIME_SERIES -> saveTimeSeries(tenantId, entityId, cfResult, cfIds, System.currentTimeMillis(), future); } - if (log.isTraceEnabled()) { - Futures.addCallback(future, new FutureCallback<>() { - @Override - public void onSuccess(Void v) { - callback.onSuccess(); - log.debug("[{}][{}] Saved CF result: {}", tenantId, entityId, cfResult); - } + Futures.addCallback(future, new FutureCallback<>() { + @Override + public void onSuccess(Void v) { + callback.onSuccess(); + log.debug("[{}][{}] Saved CF result: {}", tenantId, entityId, cfResult); + } - @Override - public void onFailure(Throwable t) { - callback.onFailure(t); - log.error("[{}][{}] Failed to save CF result {}", tenantId, entityId, cfResult, t); - } - }, MoreExecutors.directExecutor()); - } + @Override + public void onFailure(Throwable t) { + callback.onFailure(t); + log.error("[{}][{}] Failed to save CF result {}", tenantId, entityId, cfResult, t); + } + }, MoreExecutors.directExecutor()); } private void saveAttributes(TenantId tenantId, EntityId entityId, TelemetryCalculatedFieldResult cfResult, List cfIds, SettableFuture future) { diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java index f9f551f44a..b0c2b7bc38 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java @@ -96,8 +96,10 @@ public class DefaultCalculatedFieldProcessingService extends AbstractCalculatedF @Override public Map fetchDynamicArgsFromDb(CalculatedFieldCtx ctx, EntityId entityId) { return switch (ctx.getCfType()) { - case GEOFENCING -> resolveArgumentFutures(fetchGeofencingCalculatedFieldArguments(ctx, entityId, true, System.currentTimeMillis())); - case PROPAGATION -> resolveArgumentFutures(Map.of(PROPAGATION_CONFIG_ARGUMENT, fetchPropagationCalculatedFieldArgument(ctx, entityId))); + case GEOFENCING -> + resolveArgumentFutures(fetchGeofencingCalculatedFieldArguments(ctx, entityId, true, System.currentTimeMillis())); + case PROPAGATION -> + resolveArgumentFutures(Map.of(PROPAGATION_CONFIG_ARGUMENT, fetchPropagationCalculatedFieldArgument(ctx, entityId))); default -> Collections.emptyMap(); }; } @@ -137,9 +139,9 @@ public class DefaultCalculatedFieldProcessingService extends AbstractCalculatedF } TelemetryCalculatedFieldResult telemetryResult = result instanceof TelemetryCalculatedFieldResult telemetryRes ? telemetryRes : ((PropagationCalculatedFieldResult) result).getResult(); - switch (telemetryResult.getOutputStrategy().getStrategyType()) { - case IMMEDIATE -> processImmediately(tenantId, entityId, result, cfIds, callback); - case RULE_CHAIN -> pushMsgToRuleEngine(tenantId, entityId, result, cfIds, callback); + switch (telemetryResult.getOutputStrategy().getType()) { + case IMMEDIATE_ATTRIBUTES, IMMEDIATE_TIME_SERIES -> processImmediately(tenantId, entityId, result, cfIds, callback); + case RULE_CHAIN_ATTRIBUTES, RULE_CHAIN_TIME_SERIES -> pushMsgToRuleEngine(tenantId, entityId, result, cfIds, callback); } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributeImmediateOutputStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributeImmediateOutputStrategy.java index 5ca28d0ee4..d4fb961894 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributeImmediateOutputStrategy.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributeImmediateOutputStrategy.java @@ -22,7 +22,7 @@ import lombok.NoArgsConstructor; @Data @AllArgsConstructor @NoArgsConstructor -public class AttributeImmediateOutputStrategy extends ImmediateOutputStrategy { +public class AttributeImmediateOutputStrategy implements OutputStrategy { private boolean updateAttributesOnlyOnValueChange; @@ -31,7 +31,7 @@ public class AttributeImmediateOutputStrategy extends ImmediateOutputStrategy { private boolean processCfs; @Override - public String getType() { - return "IMMEDIATE_ATTRIBUTES"; + public OutputStrategyType getType() { + return OutputStrategyType.IMMEDIATE_ATTRIBUTES; } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributeRuleChainOutputStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributeRuleChainOutputStrategy.java index adefc06964..801b01401f 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributeRuleChainOutputStrategy.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributeRuleChainOutputStrategy.java @@ -20,10 +20,10 @@ import lombok.NoArgsConstructor; @Data @NoArgsConstructor -public class AttributeRuleChainOutputStrategy extends RuleChainOutputStrategy { +public class AttributeRuleChainOutputStrategy implements OutputStrategy { @Override - public String getType() { - return "RULE_CHAIN_ATTRIBUTES"; + public OutputStrategyType getType() { + return OutputStrategyType.RULE_CHAIN_ATTRIBUTES; } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ImmediateOutputStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ImmediateOutputStrategy.java deleted file mode 100644 index 170fa5bb9c..0000000000 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ImmediateOutputStrategy.java +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Copyright © 2016-2025 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.cf.configuration; - -public abstract class ImmediateOutputStrategy implements OutputStrategy { - - @Override - public OutputStrategyType getStrategyType() { - return OutputStrategyType.IMMEDIATE; - } - -} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/OutputStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/OutputStrategy.java index 8f488602fe..21dc330da3 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/OutputStrategy.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/OutputStrategy.java @@ -32,14 +32,10 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo.As; @Type(value = AttributeRuleChainOutputStrategy.class, name = "RULE_CHAIN_ATTRIBUTES"), @Type(value = TimeSeriesRuleChainOutputStrategy.class, name = "RULE_CHAIN_TIME_SERIES") - }) public interface OutputStrategy { @JsonIgnore - OutputStrategyType getStrategyType(); - - @JsonIgnore - String getType(); + OutputStrategyType getType(); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/OutputStrategyType.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/OutputStrategyType.java index 4f5234acb5..dbabb19cae 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/OutputStrategyType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/OutputStrategyType.java @@ -17,6 +17,10 @@ package org.thingsboard.server.common.data.cf.configuration; public enum OutputStrategyType { - IMMEDIATE, RULE_CHAIN + IMMEDIATE_ATTRIBUTES, + IMMEDIATE_TIME_SERIES, + + RULE_CHAIN_ATTRIBUTES, + RULE_CHAIN_TIME_SERIES } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RuleChainOutputStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RuleChainOutputStrategy.java deleted file mode 100644 index 0d601d22fc..0000000000 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RuleChainOutputStrategy.java +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Copyright © 2016-2025 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.cf.configuration; - -public abstract class RuleChainOutputStrategy implements OutputStrategy { - - @Override - public OutputStrategyType getStrategyType() { - return OutputStrategyType.RULE_CHAIN; - } - -} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesImmediateOutputStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesImmediateOutputStrategy.java index 60d5f99515..0f22a81b70 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesImmediateOutputStrategy.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesImmediateOutputStrategy.java @@ -22,7 +22,7 @@ import lombok.NoArgsConstructor; @Data @AllArgsConstructor @NoArgsConstructor -public class TimeSeriesImmediateOutputStrategy extends ImmediateOutputStrategy { +public class TimeSeriesImmediateOutputStrategy implements OutputStrategy { private long ttl; @@ -32,7 +32,7 @@ public class TimeSeriesImmediateOutputStrategy extends ImmediateOutputStrategy { private boolean processCfs; @Override - public String getType() { - return "IMMEDIATE_TIME_SERIES"; + public OutputStrategyType getType() { + return OutputStrategyType.IMMEDIATE_TIME_SERIES; } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesRuleChainOutputStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesRuleChainOutputStrategy.java index bb50cc5e8b..d5e8caa8a1 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesRuleChainOutputStrategy.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesRuleChainOutputStrategy.java @@ -20,10 +20,10 @@ import lombok.NoArgsConstructor; @Data @NoArgsConstructor -public class TimeSeriesRuleChainOutputStrategy extends RuleChainOutputStrategy { +public class TimeSeriesRuleChainOutputStrategy implements OutputStrategy { @Override - public String getType() { - return "RULE_CHAIN_TIME_SERIES"; + public OutputStrategyType getType() { + return OutputStrategyType.RULE_CHAIN_TIME_SERIES; } } From 8ca681252623133d0e09f022d00757d3c7a73fc6 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Tue, 11 Nov 2025 16:55:49 +0200 Subject: [PATCH 26/56] Refactoring --- .../server/actors/ActorSystemContext.java | 5 +++++ .../actors/ruleChain/DefaultTbContext.java | 6 ++++++ .../service/queue/DefaultTbClusterService.java | 4 ++++ .../jwt/RefreshTokenAuthenticationProvider.java | 2 +- .../auth/pat/ApiKeyAuthenticationProvider.java | 2 +- .../user/cache/DefaultUserAuthDetailsCache.java | 17 +++++------------ .../user/cache/UserAuthDetailsCache.java | 2 +- application/src/main/resources/thingsboard.yml | 2 +- .../security/auth/TokenOutdatingTest.java | 2 +- .../server/dao/pat/ApiKeyServiceImpl.java | 2 +- .../server/dao/user/UserServiceImpl.java | 2 +- .../thingsboard/rule/engine/api/TbContext.java | 3 +++ .../rule/engine/util/TenantIdLoader.java | 5 ++++- .../rule/engine/util/TenantIdLoaderTest.java | 10 +++++++++- 14 files changed, 43 insertions(+), 21 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java index 35cf9cb467..34ee4fcfd4 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java @@ -94,6 +94,7 @@ import org.thingsboard.server.dao.notification.NotificationTargetService; import org.thingsboard.server.dao.notification.NotificationTemplateService; import org.thingsboard.server.dao.oauth2.OAuth2ClientService; import org.thingsboard.server.dao.ota.OtaPackageService; +import org.thingsboard.server.dao.pat.ApiKeyService; import org.thingsboard.server.dao.queue.QueueService; import org.thingsboard.server.dao.queue.QueueStatsService; import org.thingsboard.server.dao.relation.RelationService; @@ -572,6 +573,10 @@ public class ActorSystemContext { @Getter private JobManager jobManager; + @Autowired + @Getter + private ApiKeyService apiKeyService; + @Autowired @Getter private OwnerService ownerService; diff --git a/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java index 03830c1c4c..a51eada9d7 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java @@ -109,6 +109,7 @@ import org.thingsboard.server.dao.notification.NotificationTargetService; import org.thingsboard.server.dao.notification.NotificationTemplateService; import org.thingsboard.server.dao.oauth2.OAuth2ClientService; import org.thingsboard.server.dao.ota.OtaPackageService; +import org.thingsboard.server.dao.pat.ApiKeyService; import org.thingsboard.server.dao.queue.QueueService; import org.thingsboard.server.dao.queue.QueueStatsService; import org.thingsboard.server.dao.relation.RelationService; @@ -911,6 +912,11 @@ public class DefaultTbContext implements TbContext { return mainCtx.getJobManager(); } + @Override + public ApiKeyService getApiKeyService() { + return mainCtx.getApiKeyService(); + } + @Override public boolean isExternalNodeForceAck() { return mainCtx.isExternalNodeForceAck(); diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java index 6610ecca53..386b482444 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java @@ -627,6 +627,10 @@ public class DefaultTbClusterService implements TbClusterService { // No need to push notifications twice tbRuleEngineServices.removeAll(tbCoreServices); } + if (entityType == EntityType.USER) { + // No need to push user update notification to the rule engine + return; + } for (String serviceId : tbRuleEngineServices) { TopicPartitionInfo tpi = topicService.getNotificationsTopic(ServiceType.TB_RULE_ENGINE, serviceId); ToRuleEngineNotificationMsg toRuleEngineMsg = ToRuleEngineNotificationMsg.newBuilder().setComponentLifecycle(componentLifecycleMsgProto).build(); diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenAuthenticationProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenAuthenticationProvider.java index 1aa6e046dd..5017afeb24 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenAuthenticationProvider.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenAuthenticationProvider.java @@ -76,7 +76,7 @@ public class RefreshTokenAuthenticationProvider implements AuthenticationProvide } private SecurityUser authenticateByUserId(UserId userId) { - UserAuthDetails userAuthDetails = userAuthDetailsCache.findUserEnabled(TenantId.SYS_TENANT_ID, userId); + UserAuthDetails userAuthDetails = userAuthDetailsCache.getUserAuthDetails(TenantId.SYS_TENANT_ID, userId); if (userAuthDetails == null) { throw new UsernameNotFoundException("User with credentials not found"); } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProvider.java index bcb976e9d8..b72cc4c73e 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProvider.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProvider.java @@ -67,7 +67,7 @@ public class ApiKeyAuthenticationProvider implements org.springframework.securit if (apiKey.getExpirationTime() != 0 && apiKey.getExpirationTime() < System.currentTimeMillis()) { throw new CredentialsExpiredException("API key is expired"); } - UserAuthDetails userAuthDetails = userAuthDetailsCache.findUserEnabled(apiKey.getTenantId(), apiKey.getUserId()); + UserAuthDetails userAuthDetails = userAuthDetailsCache.getUserAuthDetails(apiKey.getTenantId(), apiKey.getUserId()); if (userAuthDetails == null) { throw new UsernameNotFoundException("User with credentials not found"); } diff --git a/application/src/main/java/org/thingsboard/server/service/user/cache/DefaultUserAuthDetailsCache.java b/application/src/main/java/org/thingsboard/server/service/user/cache/DefaultUserAuthDetailsCache.java index 33730c7c92..f6c8e7f095 100644 --- a/application/src/main/java/org/thingsboard/server/service/user/cache/DefaultUserAuthDetailsCache.java +++ b/application/src/main/java/org/thingsboard/server/service/user/cache/DefaultUserAuthDetailsCache.java @@ -29,13 +29,13 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; import org.thingsboard.server.dao.user.UserService; +import org.thingsboard.server.queue.util.TbCoreComponent; import java.util.concurrent.TimeUnit; -import java.util.concurrent.locks.ReadWriteLock; -import java.util.concurrent.locks.ReentrantReadWriteLock; @Slf4j @Service +@TbCoreComponent @RequiredArgsConstructor public class DefaultUserAuthDetailsCache implements UserAuthDetailsCache { @@ -47,8 +47,6 @@ public class DefaultUserAuthDetailsCache implements UserAuthDetailsCache { private int cacheValueTtl; private Cache cache; - private final ReadWriteLock lock = new ReentrantReadWriteLock(); - @PostConstruct private void init() { cache = Caffeine.newBuilder() @@ -67,14 +65,9 @@ public class DefaultUserAuthDetailsCache implements UserAuthDetailsCache { } @Override - public UserAuthDetails findUserEnabled(TenantId tenantId, UserId userId) { - lock.readLock().lock(); - try { - log.trace("Retrieving user with enabled credentials status for id {} for tenant {} from cache", userId, tenantId); - return cache.get(userId, id -> userService.findUserAuthDetailsByUserId(tenantId, id)); - } finally { - lock.readLock().unlock(); - } + public UserAuthDetails getUserAuthDetails(TenantId tenantId, UserId userId) { + log.trace("Retrieving user with enabled credentials status for id {} for tenant {} from cache", userId, tenantId); + return cache.get(userId, id -> userService.findUserAuthDetailsByUserId(tenantId, id)); } public void evict(UserId userId) { diff --git a/application/src/main/java/org/thingsboard/server/service/user/cache/UserAuthDetailsCache.java b/application/src/main/java/org/thingsboard/server/service/user/cache/UserAuthDetailsCache.java index 6363ccae67..042d329f71 100644 --- a/application/src/main/java/org/thingsboard/server/service/user/cache/UserAuthDetailsCache.java +++ b/application/src/main/java/org/thingsboard/server/service/user/cache/UserAuthDetailsCache.java @@ -21,6 +21,6 @@ import org.thingsboard.server.common.data.id.UserId; public interface UserAuthDetailsCache { - UserAuthDetails findUserEnabled(TenantId tenantId, UserId userId); + UserAuthDetails getUserAuthDetails(TenantId tenantId, UserId userId); } diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 97585e31a7..2276583013 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -437,7 +437,7 @@ sql: ttl: "${SQL_TTL_NOTIFICATIONS_SECS:2592000}" # Default value - 30 days checking_interval_ms: "${SQL_TTL_NOTIFICATIONS_CHECKING_INTERVAL_MS:86400000}" # Default value - 1 day api_keys: - enabled: "${SQL_TTL_API_KEYS_ENABLED:true}" # Enable/disable TTL (Time To Live) for api keys records + enabled: "${SQL_TTL_API_KEYS_ENABLED:true}" # Enable/disable TTL (Time To Live) for expired api keys records checking_interval_ms: "${SQL_TTL_API_KEYS_CHECKING_INTERVAL_MS:86400000}" # Default value - 1 day relations: max_level: "${SQL_RELATIONS_MAX_LEVEL:50}" # This value has to be reasonably small to prevent infinite recursion as early as possible diff --git a/application/src/test/java/org/thingsboard/server/service/security/auth/TokenOutdatingTest.java b/application/src/test/java/org/thingsboard/server/service/security/auth/TokenOutdatingTest.java index 3726a0acff..50d6819f6f 100644 --- a/application/src/test/java/org/thingsboard/server/service/security/auth/TokenOutdatingTest.java +++ b/application/src/test/java/org/thingsboard/server/service/security/auth/TokenOutdatingTest.java @@ -97,7 +97,7 @@ public class TokenOutdatingTest { user.setId(userId); user.setAuthority(Authority.TENANT_ADMIN); user.setEmail("email"); - when(userAuthDetailsCache.findUserEnabled(any(), eq(userId))).thenReturn(new UserAuthDetails(user, true)); + when(userAuthDetailsCache.getUserAuthDetails(any(), eq(userId))).thenReturn(new UserAuthDetails(user, true)); accessTokenAuthenticationProvider = new JwtAuthenticationProvider(tokenFactory, tokenOutdatingService); refreshTokenAuthenticationProvider = new RefreshTokenAuthenticationProvider(tokenFactory, userAuthDetailsCache, mock(CustomerService.class), tokenOutdatingService); diff --git a/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyServiceImpl.java index 9352ce635b..6f1ec5d94c 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyServiceImpl.java @@ -90,7 +90,7 @@ public class ApiKeyServiceImpl extends AbstractCachedEntityService INCORRECT_USER_ID + id); return userDao.findUserAuthDetailsByUserId(tenantId.getId(), userId.getId()); } diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java index e703a7b256..8df83fb6b6 100644 --- a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java @@ -76,6 +76,7 @@ import org.thingsboard.server.dao.notification.NotificationTargetService; import org.thingsboard.server.dao.notification.NotificationTemplateService; import org.thingsboard.server.dao.oauth2.OAuth2ClientService; import org.thingsboard.server.dao.ota.OtaPackageService; +import org.thingsboard.server.dao.pat.ApiKeyService; import org.thingsboard.server.dao.queue.QueueService; import org.thingsboard.server.dao.queue.QueueStatsService; import org.thingsboard.server.dao.relation.RelationService; @@ -375,6 +376,8 @@ public interface TbContext { JobManager getJobManager(); + ApiKeyService getApiKeyService(); + boolean isExternalNodeForceAck(); /** diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TenantIdLoader.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TenantIdLoader.java index 6e4b56ccd5..55691039a3 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TenantIdLoader.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TenantIdLoader.java @@ -21,6 +21,7 @@ import org.thingsboard.server.common.data.HasTenantId; import org.thingsboard.server.common.data.cf.CalculatedFieldLink; import org.thingsboard.server.common.data.id.AiModelId; import org.thingsboard.server.common.data.id.AlarmId; +import org.thingsboard.server.common.data.id.ApiKeyId; import org.thingsboard.server.common.data.id.ApiUsageStateId; import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.AssetProfileId; @@ -148,7 +149,6 @@ public class TenantIdLoader { break; case NOTIFICATION: case ADMIN_SETTINGS: - case API_KEY: return ctxTenantId; case NOTIFICATION_RULE: tenantEntity = ctx.getNotificationRuleService().findNotificationRuleById(ctxTenantId, new NotificationRuleId(id)); @@ -185,6 +185,9 @@ public class TenantIdLoader { case AI_MODEL: tenantEntity = ctx.getAiModelService().findAiModelById(ctxTenantId, new AiModelId(id)).orElse(null); break; + case API_KEY: + tenantEntity = ctx.getApiKeyService().findApiKeyById(ctxTenantId, new ApiKeyId(id)); + break; default: throw new RuntimeException("Unexpected entity type: " + entityId.getEntityType()); } diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/TenantIdLoaderTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/TenantIdLoaderTest.java index f973bad105..13bbfa2eba 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/TenantIdLoaderTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/TenantIdLoaderTest.java @@ -62,6 +62,7 @@ import org.thingsboard.server.common.data.notification.rule.NotificationRule; import org.thingsboard.server.common.data.notification.targets.NotificationTarget; import org.thingsboard.server.common.data.notification.template.NotificationTemplate; import org.thingsboard.server.common.data.oauth2.OAuth2Client; +import org.thingsboard.server.common.data.pat.ApiKey; import org.thingsboard.server.common.data.queue.Queue; import org.thingsboard.server.common.data.queue.QueueStats; import org.thingsboard.server.common.data.rpc.Rpc; @@ -169,6 +170,8 @@ public class TenantIdLoaderTest { private JobService jobService; @Mock private AiModelService aiModelService; + @Mock + private ApiKeyService apiKeyService; private TenantId tenantId; private TenantProfileId tenantProfileId; @@ -203,7 +206,6 @@ public class TenantIdLoaderTest { case TENANT: case NOTIFICATION: case ADMIN_SETTINGS: - case API_KEY: break; case CUSTOMER: Customer customer = new Customer(); @@ -400,6 +402,12 @@ public class TenantIdLoaderTest { when(ctx.getAiModelService()).thenReturn(aiModelService); doReturn(Optional.of(aiModel)).when(aiModelService).findAiModelById(eq(tenantId), any()); break; + case API_KEY: + ApiKey apiKey = new ApiKey(); + apiKey.setTenantId(tenantId); + when(ctx.getApiKeyService()).thenReturn(apiKeyService); + doReturn(apiKey).when(apiKeyService).findApiKeyById(eq(tenantId), any()); + break; default: throw new RuntimeException("Unexpected originator EntityType " + entityType); } From a39e8f79b2d97c9ef98cee868a64cd4d4763f954 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Tue, 11 Nov 2025 16:58:33 +0200 Subject: [PATCH 27/56] Fix cluster service send to re --- .../service/queue/DefaultTbClusterService.java | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java index 386b482444..229a6c8eec 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java @@ -627,15 +627,14 @@ public class DefaultTbClusterService implements TbClusterService { // No need to push notifications twice tbRuleEngineServices.removeAll(tbCoreServices); } - if (entityType == EntityType.USER) { - // No need to push user update notification to the rule engine - return; - } - for (String serviceId : tbRuleEngineServices) { - TopicPartitionInfo tpi = topicService.getNotificationsTopic(ServiceType.TB_RULE_ENGINE, serviceId); - ToRuleEngineNotificationMsg toRuleEngineMsg = ToRuleEngineNotificationMsg.newBuilder().setComponentLifecycle(componentLifecycleMsgProto).build(); - toRuleEngineProducer.send(tpi, new TbProtoQueueMsg<>(msg.getEntityId().getId(), toRuleEngineMsg), null); - toRuleEngineNfs.incrementAndGet(); + boolean toRuleEngine = entityType != EntityType.USER; + if (toRuleEngine) { + for (String serviceId : tbRuleEngineServices) { + TopicPartitionInfo tpi = topicService.getNotificationsTopic(ServiceType.TB_RULE_ENGINE, serviceId); + ToRuleEngineNotificationMsg toRuleEngineMsg = ToRuleEngineNotificationMsg.newBuilder().setComponentLifecycle(componentLifecycleMsgProto).build(); + toRuleEngineProducer.send(tpi, new TbProtoQueueMsg<>(msg.getEntityId().getId(), toRuleEngineMsg), null); + toRuleEngineNfs.incrementAndGet(); + } } } From 558a13b5b020ce311c9d60144d55513b9cf9159a Mon Sep 17 00:00:00 2001 From: IrynaMatveieva Date: Wed, 12 Nov 2025 10:11:53 +0200 Subject: [PATCH 28/56] refactored cf output --- .../main/data/upgrade/basic/schema_update.sql | 6 +- ...faultCalculatedFieldProcessingService.java | 10 ++- .../cf/CalculatedFieldCurrentOwnerTest.java | 6 +- .../cf/CalculatedFieldIntegrationTest.java | 65 +++++++------------ ...ntitiesAggregationCalculatedFieldTest.java | 15 ++--- .../CalculatedFieldControllerTest.java | 16 ++--- .../server/edge/CalculatedFieldEdgeTest.java | 6 +- .../GeofencingCalculatedFieldStateTest.java | 6 +- .../PropagationCalculatedFieldStateTest.java | 5 +- .../state/ScriptCalculatedFieldStateTest.java | 5 +- .../state/SimpleCalculatedFieldStateTest.java | 8 +-- .../sync/ie/ExportImportServiceSqlTest.java | 6 +- .../service/sync/vc/VersionControlTest.java | 6 +- .../AttributeImmediateOutputStrategy.java | 4 +- .../AttributeOutputStrategy.java | 33 ++++++++++ .../AttributeRuleChainOutputStrategy.java | 4 +- .../cf/configuration/AttributesOutput.java | 38 +++++++++++ .../common/data/cf/configuration/Output.java | 35 +++++++--- .../data/cf/configuration/OutputStrategy.java | 16 ----- .../cf/configuration/OutputStrategyType.java | 6 +- .../TimeSeriesImmediateOutputStrategy.java | 4 +- .../cf/configuration/TimeSeriesOutput.java | 37 +++++++++++ .../TimeSeriesOutputStrategy.java | 31 +++++++++ .../TimeSeriesRuleChainOutputStrategy.java | 4 +- .../server/dao/service/AssetServiceTest.java | 6 +- .../service/CalculatedFieldServiceTest.java | 10 ++- .../dao/service/CustomerServiceTest.java | 6 +- .../server/dao/service/DeviceServiceTest.java | 6 +- .../service/MonitoringEntityService.java | 7 +- 29 files changed, 244 insertions(+), 163 deletions(-) create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributeOutputStrategy.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributesOutput.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesOutput.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesOutputStrategy.java diff --git a/application/src/main/data/upgrade/basic/schema_update.sql b/application/src/main/data/upgrade/basic/schema_update.sql index 05726a92e9..9bd3d46208 100644 --- a/application/src/main/data/upgrade/basic/schema_update.sql +++ b/application/src/main/data/upgrade/basic/schema_update.sql @@ -80,11 +80,7 @@ SET configuration = jsonb_set( || jsonb_build_object( 'strategy', jsonb_build_object( - 'type', - CASE (configuration::jsonb -> 'output' ->> 'type') - WHEN 'TIME_SERIES' THEN 'RULE_CHAIN_TIME_SERIES' - WHEN 'ATTRIBUTES' THEN 'RULE_CHAIN_ATTRIBUTES' - END + 'type', 'RULE_CHAIN' ) ), false diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java index b0c2b7bc38..302f51ecb1 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java @@ -96,10 +96,8 @@ public class DefaultCalculatedFieldProcessingService extends AbstractCalculatedF @Override public Map fetchDynamicArgsFromDb(CalculatedFieldCtx ctx, EntityId entityId) { return switch (ctx.getCfType()) { - case GEOFENCING -> - resolveArgumentFutures(fetchGeofencingCalculatedFieldArguments(ctx, entityId, true, System.currentTimeMillis())); - case PROPAGATION -> - resolveArgumentFutures(Map.of(PROPAGATION_CONFIG_ARGUMENT, fetchPropagationCalculatedFieldArgument(ctx, entityId))); + case GEOFENCING -> resolveArgumentFutures(fetchGeofencingCalculatedFieldArguments(ctx, entityId, true, System.currentTimeMillis())); + case PROPAGATION -> resolveArgumentFutures(Map.of(PROPAGATION_CONFIG_ARGUMENT, fetchPropagationCalculatedFieldArgument(ctx, entityId))); default -> Collections.emptyMap(); }; } @@ -140,8 +138,8 @@ public class DefaultCalculatedFieldProcessingService extends AbstractCalculatedF TelemetryCalculatedFieldResult telemetryResult = result instanceof TelemetryCalculatedFieldResult telemetryRes ? telemetryRes : ((PropagationCalculatedFieldResult) result).getResult(); switch (telemetryResult.getOutputStrategy().getType()) { - case IMMEDIATE_ATTRIBUTES, IMMEDIATE_TIME_SERIES -> processImmediately(tenantId, entityId, result, cfIds, callback); - case RULE_CHAIN_ATTRIBUTES, RULE_CHAIN_TIME_SERIES -> pushMsgToRuleEngine(tenantId, entityId, result, cfIds, callback); + case IMMEDIATE -> processImmediately(tenantId, entityId, result, cfIds, callback); + case RULE_CHAIN -> pushMsgToRuleEngine(tenantId, entityId, result, cfIds, callback); } } diff --git a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldCurrentOwnerTest.java b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldCurrentOwnerTest.java index d2f9621064..6c6401f088 100644 --- a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldCurrentOwnerTest.java +++ b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldCurrentOwnerTest.java @@ -26,10 +26,9 @@ import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentType; import org.thingsboard.server.common.data.cf.configuration.CurrentOwnerDynamicSourceConfiguration; -import org.thingsboard.server.common.data.cf.configuration.Output; -import org.thingsboard.server.common.data.cf.configuration.OutputType; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.TimeSeriesOutput; import org.thingsboard.server.common.data.id.AssetProfileId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.controller.AbstractControllerTest; @@ -175,9 +174,8 @@ public class CalculatedFieldCurrentOwnerTest extends AbstractControllerTest { config.setExpression("a + 100"); - Output output = new Output(); + TimeSeriesOutput output = new TimeSeriesOutput(); output.setName("result"); - output.setType(OutputType.TIME_SERIES); output.setDecimalsByDefault(0); config.setOutput(output); diff --git a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java index 8593ea8ae5..1d535a96c2 100644 --- a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java @@ -33,15 +33,15 @@ import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentType; +import org.thingsboard.server.common.data.cf.configuration.AttributesOutput; import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; -import org.thingsboard.server.common.data.cf.configuration.Output; -import org.thingsboard.server.common.data.cf.configuration.OutputType; import org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration; import org.thingsboard.server.common.data.cf.configuration.ScriptCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.TimeSeriesImmediateOutputStrategy; +import org.thingsboard.server.common.data.cf.configuration.TimeSeriesOutput; import org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates; import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.geofencing.ZoneGroupConfiguration; @@ -94,13 +94,11 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes config.setArguments(Map.of("T", argument)); config.setExpression("(T * 9/5) + 32"); - Output output = new Output(); + TimeSeriesOutput output = new TimeSeriesOutput(); output.setName("fahrenheitTemp"); - output.setType(OutputType.TIME_SERIES); config.setOutput(output); calculatedField.setConfiguration(config); - calculatedField.setVersion(1L); CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); @@ -122,10 +120,12 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").asText()).isEqualTo("86.0"); }); - Output savedOutput = savedCalculatedField.getConfiguration().getOutput(); - savedOutput.setType(OutputType.ATTRIBUTES); - savedOutput.setScope(AttributeScope.SERVER_SCOPE); - savedOutput.setName("temperatureF"); + AttributesOutput newOutput = new AttributesOutput(); + newOutput.setScope(AttributeScope.SERVER_SCOPE); + newOutput.setName("temperatureF"); + config.setOutput(newOutput); + savedCalculatedField.setConfiguration(config); + savedCalculatedField = doPost("/api/calculatedField", savedCalculatedField, CalculatedField.class); await().alias("update CF output -> perform calculation with updated output").atMost(TIMEOUT, TimeUnit.SECONDS) @@ -180,9 +180,8 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes config.setArguments(Map.of("T", argument)); config.setExpression("(T * 9/5) + 32"); - Output output = new Output(); + TimeSeriesOutput output = new TimeSeriesOutput(); output.setName("fahrenheitTemp"); - output.setType(OutputType.TIME_SERIES); config.setOutput(output); calculatedField.setConfiguration(config); @@ -229,9 +228,8 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes config.setArguments(Map.of("T", argument)); config.setExpression("(T * 9/5) + 32"); - Output output = new Output(); + TimeSeriesOutput output = new TimeSeriesOutput(); output.setName("fahrenheitTemp"); - output.setType(OutputType.TIME_SERIES); config.setOutput(output); calculatedField.setConfiguration(config); @@ -293,9 +291,8 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes config.setExpression("x + y"); - Output output = new Output(); + AttributesOutput output = new AttributesOutput(); output.setName("z"); - output.setType(OutputType.ATTRIBUTES); output.setScope(AttributeScope.SERVER_SCOPE); config.setOutput(output); @@ -450,9 +447,8 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes config.setArguments(Map.of("T", argument)); config.setExpression("(T * 9/0) + 32"); - Output output = new Output(); + TimeSeriesOutput output = new TimeSeriesOutput(); output.setName("fahrenheitTemp"); - output.setType(OutputType.TIME_SERIES); config.setOutput(output); calculatedField.setConfiguration(config); @@ -500,9 +496,8 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes config.setArguments(Map.of("T", argument)); config.setExpression("(T * 9/5) + 32"); - Output output = new Output(); + TimeSeriesOutput output = new TimeSeriesOutput(); output.setName("fahrenheitTemp"); - output.setType(OutputType.TIME_SERIES); config.setOutput(output); config.setUseLatestTs(true); @@ -550,9 +545,8 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes config.setArguments(Map.of("a", argument1, "b", argument2)); config.setExpression("a + b"); - Output output = new Output(); + TimeSeriesOutput output = new TimeSeriesOutput(); output.setName("c"); - output.setType(OutputType.TIME_SERIES); config.setOutput(output); config.setUseLatestTs(true); @@ -604,9 +598,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes config.setArguments(Map.of("T", argument)); config.setExpression("return {\"ts\": ctx.latestTs, \"values\": {\"fahrenheitTemp\": (T * 1.8) + 32}};"); - Output output = new Output(); - output.setType(OutputType.TIME_SERIES); - config.setOutput(output); + config.setOutput(new TimeSeriesOutput()); calculatedField.setConfiguration(config); @@ -640,9 +632,8 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes config.setArguments(Map.of("m", argument)); config.setExpression("m + 1"); - Output output = new Output(); + TimeSeriesOutput output = new TimeSeriesOutput(); output.setName("m1"); - output.setType(OutputType.TIME_SERIES); output.setDecimalsByDefault(0); config.setOutput(output); @@ -715,8 +706,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes cfg.setZoneGroups(Map.of("allowedZones", allowedZonesGroup, "restrictedZones", restrictedZonesGroup)); // Output to server attributes - Output out = new Output(); - out.setType(OutputType.ATTRIBUTES); + AttributesOutput out = new AttributesOutput(); out.setScope(AttributeScope.SERVER_SCOPE); cfg.setOutput(out); @@ -826,8 +816,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes cfg.setZoneGroups(Map.of("allowedZones", allowedZonesGroup, "restrictedZones", restrictedZonesGroup)); // Output to server attributes - Output out = new Output(); - out.setType(OutputType.ATTRIBUTES); + AttributesOutput out = new AttributesOutput(); out.setScope(AttributeScope.SERVER_SCOPE); cfg.setOutput(out); @@ -924,8 +913,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes cfg.setZoneGroups(Map.of("allowedZones", allowedZonesGroup)); // Server attributes output - Output out = new Output(); - out.setType(OutputType.ATTRIBUTES); + AttributesOutput out = new AttributesOutput(); out.setScope(AttributeScope.SERVER_SCOPE); cfg.setOutput(out); @@ -1035,8 +1023,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes cfg.setExpression("{\"testResult\": t * 2}"); - Output output = new Output(); - output.setType(OutputType.ATTRIBUTES); + AttributesOutput output = new AttributesOutput(); output.setScope(AttributeScope.SERVER_SCOPE); cfg.setOutput(output); @@ -1112,9 +1099,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes arg.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); cfg.setArguments(Map.of("temperatureComputed", arg)); - Output output = new Output(); - output.setType(OutputType.TIME_SERIES); - cfg.setOutput(output); + cfg.setOutput(new TimeSeriesOutput()); cf.setConfiguration(cfg); @@ -1182,9 +1167,8 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes config.setArguments(Map.of("a", argumentA, "b", argumentB)); config.setExpression("a + b"); - Output output = new Output(); + TimeSeriesOutput output = new TimeSeriesOutput(); output.setName("c"); - output.setType(OutputType.TIME_SERIES); output.setDecimalsByDefault(0); config.setOutput(output); @@ -1231,9 +1215,8 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes config.setArguments(Map.of("T", argument)); config.setExpression("(T * 9/5) + 32"); - Output output = new Output(); + TimeSeriesOutput output = new TimeSeriesOutput(); output.setName("fahrenheitTemp"); - output.setType(OutputType.TIME_SERIES); output.setDecimalsByDefault(1); output.setStrategy(new TimeSeriesImmediateOutputStrategy(1000L, true, true, true, true)); diff --git a/application/src/test/java/org/thingsboard/server/cf/RelatedEntitiesAggregationCalculatedFieldTest.java b/application/src/test/java/org/thingsboard/server/cf/RelatedEntitiesAggregationCalculatedFieldTest.java index 48c4e67608..3739cc2618 100644 --- a/application/src/test/java/org/thingsboard/server/cf/RelatedEntitiesAggregationCalculatedFieldTest.java +++ b/application/src/test/java/org/thingsboard/server/cf/RelatedEntitiesAggregationCalculatedFieldTest.java @@ -32,9 +32,10 @@ import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentType; +import org.thingsboard.server.common.data.cf.configuration.AttributesOutput; import org.thingsboard.server.common.data.cf.configuration.Output; -import org.thingsboard.server.common.data.cf.configuration.OutputType; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; +import org.thingsboard.server.common.data.cf.configuration.TimeSeriesOutput; import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction; import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunctionInput; import org.thingsboard.server.common.data.cf.configuration.aggregation.AggKeyInput; @@ -626,8 +627,7 @@ public class RelatedEntitiesAggregationCalculatedFieldTest extends AbstractContr }); var configuration = (RelatedEntitiesAggregationCalculatedFieldConfiguration) cf.getConfiguration(); - Output output = new Output(); - output.setType(OutputType.ATTRIBUTES); + AttributesOutput output = new AttributesOutput(); output.setScope(AttributeScope.SERVER_SCOPE); configuration.setOutput(output); saveCalculatedField(cf); @@ -702,8 +702,7 @@ public class RelatedEntitiesAggregationCalculatedFieldTest extends AbstractContr avgMetric.setInput(new AggKeyInput("temp")); aggMetrics.put("avgTemperature", avgMetric); - Output output = new Output(); - output.setType(OutputType.TIME_SERIES); + TimeSeriesOutput output = new TimeSeriesOutput(); output.setDecimalsByDefault(0); return createAggCf("Average temperature", entityId, @@ -739,8 +738,7 @@ public class RelatedEntitiesAggregationCalculatedFieldTest extends AbstractContr totalSpaces.setInput(new AggFunctionInput("return 1;")); aggMetrics.put("totalSpaces", totalSpaces); - Output output = new Output(); - output.setType(OutputType.TIME_SERIES); + TimeSeriesOutput output = new TimeSeriesOutput(); output.setDecimalsByDefault(0); return createAggCf("Occupied spaces", entityId, @@ -776,8 +774,7 @@ public class RelatedEntitiesAggregationCalculatedFieldTest extends AbstractContr totalSpaces.setInput(new AggFunctionInput("return 1;")); aggMetrics.put("totalSpaces", totalSpaces); - Output output = new Output(); - output.setType(OutputType.TIME_SERIES); + TimeSeriesOutput output = new TimeSeriesOutput(); output.setDecimalsByDefault(0); return createAggCf("Occupied spaces", entityId, diff --git a/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java index 4ebace6ae7..b99b416c10 100644 --- a/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java @@ -26,12 +26,11 @@ import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentType; import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; -import org.thingsboard.server.common.data.cf.configuration.Output; -import org.thingsboard.server.common.data.cf.configuration.OutputType; import org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration; import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.TimeSeriesOutput; import org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates; import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.geofencing.ZoneGroupConfiguration; @@ -251,13 +250,10 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest { var zoneGroupConfiguration = new ZoneGroupConfiguration("perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); zoneGroupConfiguration.setRefDynamicSourceConfiguration(refDynamicSourceConfiguration); - Output output = new Output(); - output.setType(OutputType.TIME_SERIES); - config.setEntityCoordinates(new EntityCoordinates("latitide", "longitude")); config.setZoneGroups(Map.of("safeArea", zoneGroupConfiguration)); config.setScheduledUpdateEnabled(false); - config.setOutput(output); + config.setOutput(new TimeSeriesOutput()); return config; } @@ -275,10 +271,7 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest { config.setApplyExpressionToResolvedArguments(false); config.setExpression(null); - - Output output = new Output(); - output.setType(OutputType.TIME_SERIES); - config.setOutput(output); + config.setOutput(new TimeSeriesOutput()); Argument arg = new Argument(); arg.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); @@ -299,9 +292,8 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest { config.setExpression("T - (100 - H) / 5"); - Output output = new Output(); + TimeSeriesOutput output = new TimeSeriesOutput(); output.setName("output"); - output.setType(OutputType.TIME_SERIES); config.setOutput(output); diff --git a/application/src/test/java/org/thingsboard/server/edge/CalculatedFieldEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/CalculatedFieldEdgeTest.java index 268e19345c..4161ff845d 100644 --- a/application/src/test/java/org/thingsboard/server/edge/CalculatedFieldEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/CalculatedFieldEdgeTest.java @@ -26,10 +26,9 @@ import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentType; -import org.thingsboard.server.common.data.cf.configuration.Output; -import org.thingsboard.server.common.data.cf.configuration.OutputType; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.TimeSeriesOutput; import org.thingsboard.server.common.data.debug.DebugSettings; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.dao.service.DaoSqlTest; @@ -224,9 +223,8 @@ public class CalculatedFieldEdgeTest extends AbstractEdgeTest { config.setExpression("(T * 9/5) + 32"); - Output output = new Output(); + TimeSeriesOutput output = new TimeSeriesOutput(); output.setName("fahrenheitTemp"); - output.setType(OutputType.TIME_SERIES); output.setDecimalsByDefault(2); config.setOutput(output); diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldStateTest.java index d144fe4dcc..6b846b3b15 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldStateTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldStateTest.java @@ -29,8 +29,8 @@ import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.Output; -import org.thingsboard.server.common.data.cf.configuration.OutputType; import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration; +import org.thingsboard.server.common.data.cf.configuration.TimeSeriesOutput; import org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates; import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingReportStrategy; @@ -476,9 +476,7 @@ public class GeofencingCalculatedFieldStateTest { config.setZoneGroups(Map.of("allowedZones", allowedZonesGroup, "restrictedZones", restrictedZonesGroup)); - Output output = new Output(); - output.setType(OutputType.TIME_SERIES); - config.setOutput(output); + config.setOutput(new TimeSeriesOutput()); return config; } diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationCalculatedFieldStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationCalculatedFieldStateTest.java index ddb9f378b0..d5546f4fea 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationCalculatedFieldStateTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationCalculatedFieldStateTest.java @@ -31,8 +31,8 @@ import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentType; +import org.thingsboard.server.common.data.cf.configuration.AttributesOutput; import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; -import org.thingsboard.server.common.data.cf.configuration.Output; import org.thingsboard.server.common.data.cf.configuration.OutputType; import org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; @@ -235,8 +235,7 @@ public class PropagationCalculatedFieldStateTest { config.setArguments(Map.of(TEMPERATURE_ARGUMENT_NAME, temperatureArg)); config.setExpression("{" + TEST_RESULT_EXPRESSION_KEY + ": " + TEMPERATURE_ARGUMENT_NAME + " * 2}"); - Output output = new Output(); - output.setType(OutputType.ATTRIBUTES); + AttributesOutput output = new AttributesOutput(); output.setScope(AttributeScope.SERVER_SCOPE); config.setOutput(output); diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldStateTest.java index 51e633a232..96ca21c769 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldStateTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldStateTest.java @@ -31,9 +31,9 @@ import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentType; +import org.thingsboard.server.common.data.cf.configuration.AttributesOutput; import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.Output; -import org.thingsboard.server.common.data.cf.configuration.OutputType; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; import org.thingsboard.server.common.data.id.AssetId; @@ -221,8 +221,7 @@ public class ScriptCalculatedFieldStateTest { config.setExpression("return {\"maxDeviceTemperature\": deviceTemperature.max(), \"assetHumidity\": assetHumidity / 2 }"); - Output output = new Output(); - output.setType(OutputType.ATTRIBUTES); + AttributesOutput output = new AttributesOutput(); output.setScope(AttributeScope.SERVER_SCOPE); config.setOutput(output); diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java index a64f1e4c60..79b05a1e41 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java @@ -28,11 +28,12 @@ import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentType; +import org.thingsboard.server.common.data.cf.configuration.AttributesOutput; import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.Output; -import org.thingsboard.server.common.data.cf.configuration.OutputType; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.TimeSeriesOutput; import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.TenantId; @@ -189,7 +190,7 @@ public class SimpleCalculatedFieldStateTest { "key3", new SingleValueArgumentEntry(System.currentTimeMillis() - 3, new DoubleDataEntry("key3", 23.1), 184L) )); - Output output = getCalculatedFieldConfig().getOutput(); + TimeSeriesOutput output = (TimeSeriesOutput) getCalculatedFieldConfig().getOutput(); output.setDecimalsByDefault(3); ctx.setOutput(output); @@ -263,9 +264,8 @@ public class SimpleCalculatedFieldStateTest { config.setExpression("key1 + key2 + key3"); - Output output = new Output(); + AttributesOutput output = new AttributesOutput(); output.setName("output"); - output.setType(OutputType.ATTRIBUTES); output.setScope(AttributeScope.SERVER_SCOPE); output.setDecimalsByDefault(0); diff --git a/application/src/test/java/org/thingsboard/server/service/sync/ie/ExportImportServiceSqlTest.java b/application/src/test/java/org/thingsboard/server/service/sync/ie/ExportImportServiceSqlTest.java index e70a0cd37c..70711de64d 100644 --- a/application/src/test/java/org/thingsboard/server/service/sync/ie/ExportImportServiceSqlTest.java +++ b/application/src/test/java/org/thingsboard/server/service/sync/ie/ExportImportServiceSqlTest.java @@ -48,10 +48,9 @@ import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentType; import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; -import org.thingsboard.server.common.data.cf.configuration.Output; -import org.thingsboard.server.common.data.cf.configuration.OutputType; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.TimeSeriesOutput; import org.thingsboard.server.common.data.device.data.DefaultDeviceTransportConfiguration; import org.thingsboard.server.common.data.device.data.DeviceData; import org.thingsboard.server.common.data.device.profile.DefaultDeviceProfileConfiguration; @@ -627,9 +626,8 @@ public class ExportImportServiceSqlTest extends AbstractControllerTest { config.setExpression("T - (100 - H) / 5"); - Output output = new Output(); + TimeSeriesOutput output = new TimeSeriesOutput(); output.setName("output"); - output.setType(OutputType.TIME_SERIES); config.setOutput(output); diff --git a/application/src/test/java/org/thingsboard/server/service/sync/vc/VersionControlTest.java b/application/src/test/java/org/thingsboard/server/service/sync/vc/VersionControlTest.java index 2db78871d4..2f2a584432 100644 --- a/application/src/test/java/org/thingsboard/server/service/sync/vc/VersionControlTest.java +++ b/application/src/test/java/org/thingsboard/server/service/sync/vc/VersionControlTest.java @@ -52,10 +52,9 @@ import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentType; import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; -import org.thingsboard.server.common.data.cf.configuration.Output; -import org.thingsboard.server.common.data.cf.configuration.OutputType; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.TimeSeriesOutput; import org.thingsboard.server.common.data.debug.DebugSettings; import org.thingsboard.server.common.data.device.data.DefaultDeviceTransportConfiguration; import org.thingsboard.server.common.data.device.data.DeviceData; @@ -1148,9 +1147,8 @@ public class VersionControlTest extends AbstractControllerTest { config.setExpression("T - (100 - H) / 5"); - Output output = new Output(); + TimeSeriesOutput output = new TimeSeriesOutput(); output.setName("output"); - output.setType(OutputType.TIME_SERIES); config.setOutput(output); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributeImmediateOutputStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributeImmediateOutputStrategy.java index d4fb961894..737c4fc64e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributeImmediateOutputStrategy.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributeImmediateOutputStrategy.java @@ -22,7 +22,7 @@ import lombok.NoArgsConstructor; @Data @AllArgsConstructor @NoArgsConstructor -public class AttributeImmediateOutputStrategy implements OutputStrategy { +public class AttributeImmediateOutputStrategy implements AttributeOutputStrategy { private boolean updateAttributesOnlyOnValueChange; @@ -32,6 +32,6 @@ public class AttributeImmediateOutputStrategy implements OutputStrategy { @Override public OutputStrategyType getType() { - return OutputStrategyType.IMMEDIATE_ATTRIBUTES; + return OutputStrategyType.IMMEDIATE; } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributeOutputStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributeOutputStrategy.java new file mode 100644 index 0000000000..f47bc27579 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributeOutputStrategy.java @@ -0,0 +1,33 @@ +/** + * Copyright © 2016-2025 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.cf.configuration; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "type" +) +@JsonSubTypes({ + @JsonSubTypes.Type(value = AttributeImmediateOutputStrategy.class, name = "IMMEDIATE"), + @JsonSubTypes.Type(value = AttributeRuleChainOutputStrategy.class, name = "RULE_CHAIN"), +}) +public interface AttributeOutputStrategy extends OutputStrategy { +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributeRuleChainOutputStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributeRuleChainOutputStrategy.java index 801b01401f..ce03aeb750 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributeRuleChainOutputStrategy.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributeRuleChainOutputStrategy.java @@ -20,10 +20,10 @@ import lombok.NoArgsConstructor; @Data @NoArgsConstructor -public class AttributeRuleChainOutputStrategy implements OutputStrategy { +public class AttributeRuleChainOutputStrategy implements AttributeOutputStrategy { @Override public OutputStrategyType getType() { - return OutputStrategyType.RULE_CHAIN_ATTRIBUTES; + return OutputStrategyType.RULE_CHAIN; } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributesOutput.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributesOutput.java new file mode 100644 index 0000000000..61195fc5f4 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributesOutput.java @@ -0,0 +1,38 @@ +/** + * Copyright © 2016-2025 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.cf.configuration; + +import lombok.Data; +import org.thingsboard.server.common.data.AttributeScope; + +@Data +public class AttributesOutput implements Output { + + private String name; + private AttributeScope scope; + private Integer decimalsByDefault; + + private AttributeOutputStrategy strategy; + + public AttributesOutput() { + this.strategy = new AttributeRuleChainOutputStrategy(); + } + + @Override + public OutputType getType() { + return OutputType.ATTRIBUTES; + } +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Output.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Output.java index d2e0ac1ca9..4658d41221 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Output.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Output.java @@ -15,18 +15,37 @@ */ package org.thingsboard.server.common.data.cf.configuration; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; -import lombok.Data; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; import org.thingsboard.server.common.data.AttributeScope; -@Data +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "type" +) +@JsonSubTypes({ + @JsonSubTypes.Type(value = TimeSeriesOutput.class, name = "TIME_SERIES"), + @JsonSubTypes.Type(value = AttributesOutput.class, name = "ATTRIBUTES") +}) @JsonInclude(JsonInclude.Include.NON_NULL) -public class Output { +@JsonIgnoreProperties(ignoreUnknown = true) +public interface Output { - private String name; - private OutputType type; - private AttributeScope scope; - private Integer decimalsByDefault; - private OutputStrategy strategy; + @JsonIgnore + OutputType getType(); + + String getName(); + + OutputStrategy getStrategy(); + + default AttributeScope getScope() { + return null; + } + + Integer getDecimalsByDefault(); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/OutputStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/OutputStrategy.java index 21dc330da3..b635047ae3 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/OutputStrategy.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/OutputStrategy.java @@ -16,23 +16,7 @@ package org.thingsboard.server.common.data.cf.configuration; import com.fasterxml.jackson.annotation.JsonIgnore; -import com.fasterxml.jackson.annotation.JsonSubTypes; -import com.fasterxml.jackson.annotation.JsonSubTypes.Type; -import com.fasterxml.jackson.annotation.JsonTypeInfo; -import com.fasterxml.jackson.annotation.JsonTypeInfo.As; -@JsonTypeInfo( - use = JsonTypeInfo.Id.NAME, - include = As.PROPERTY, - property = "type" -) -@JsonSubTypes({ - @Type(value = AttributeImmediateOutputStrategy.class, name = "IMMEDIATE_ATTRIBUTES"), - @Type(value = TimeSeriesImmediateOutputStrategy.class, name = "IMMEDIATE_TIME_SERIES"), - - @Type(value = AttributeRuleChainOutputStrategy.class, name = "RULE_CHAIN_ATTRIBUTES"), - @Type(value = TimeSeriesRuleChainOutputStrategy.class, name = "RULE_CHAIN_TIME_SERIES") -}) public interface OutputStrategy { @JsonIgnore diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/OutputStrategyType.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/OutputStrategyType.java index dbabb19cae..4f5234acb5 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/OutputStrategyType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/OutputStrategyType.java @@ -17,10 +17,6 @@ package org.thingsboard.server.common.data.cf.configuration; public enum OutputStrategyType { - IMMEDIATE_ATTRIBUTES, - IMMEDIATE_TIME_SERIES, - - RULE_CHAIN_ATTRIBUTES, - RULE_CHAIN_TIME_SERIES + IMMEDIATE, RULE_CHAIN } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesImmediateOutputStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesImmediateOutputStrategy.java index 0f22a81b70..31b15095d4 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesImmediateOutputStrategy.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesImmediateOutputStrategy.java @@ -22,7 +22,7 @@ import lombok.NoArgsConstructor; @Data @AllArgsConstructor @NoArgsConstructor -public class TimeSeriesImmediateOutputStrategy implements OutputStrategy { +public class TimeSeriesImmediateOutputStrategy implements TimeSeriesOutputStrategy { private long ttl; @@ -33,6 +33,6 @@ public class TimeSeriesImmediateOutputStrategy implements OutputStrategy { @Override public OutputStrategyType getType() { - return OutputStrategyType.IMMEDIATE_TIME_SERIES; + return OutputStrategyType.IMMEDIATE; } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesOutput.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesOutput.java new file mode 100644 index 0000000000..5c6a907290 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesOutput.java @@ -0,0 +1,37 @@ +/** + * Copyright © 2016-2025 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.cf.configuration; + +import lombok.Data; + +@Data +public class TimeSeriesOutput implements Output { + + private String name; + private Integer decimalsByDefault; + + private TimeSeriesOutputStrategy strategy; + + public TimeSeriesOutput() { + this.strategy = new TimeSeriesRuleChainOutputStrategy(); + } + + @Override + public OutputType getType() { + return OutputType.TIME_SERIES; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesOutputStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesOutputStrategy.java new file mode 100644 index 0000000000..303c180c46 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesOutputStrategy.java @@ -0,0 +1,31 @@ +/** + * Copyright © 2016-2025 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.cf.configuration; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "type" +) +@JsonSubTypes({ + @JsonSubTypes.Type(value = TimeSeriesImmediateOutputStrategy.class, name = "IMMEDIATE"), + @JsonSubTypes.Type(value = TimeSeriesRuleChainOutputStrategy.class, name = "RULE_CHAIN") +}) +public interface TimeSeriesOutputStrategy extends OutputStrategy { +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesRuleChainOutputStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesRuleChainOutputStrategy.java index d5e8caa8a1..7786efff1a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesRuleChainOutputStrategy.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesRuleChainOutputStrategy.java @@ -20,10 +20,10 @@ import lombok.NoArgsConstructor; @Data @NoArgsConstructor -public class TimeSeriesRuleChainOutputStrategy implements OutputStrategy { +public class TimeSeriesRuleChainOutputStrategy implements TimeSeriesOutputStrategy { @Override public OutputStrategyType getType() { - return OutputStrategyType.RULE_CHAIN_TIME_SERIES; + return OutputStrategyType.RULE_CHAIN; } } diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/AssetServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/AssetServiceTest.java index 462e7a894c..7bea8f6f74 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/AssetServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/AssetServiceTest.java @@ -34,10 +34,9 @@ import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentType; -import org.thingsboard.server.common.data.cf.configuration.Output; -import org.thingsboard.server.common.data.cf.configuration.OutputType; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.TimeSeriesOutput; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; @@ -894,9 +893,8 @@ public class AssetServiceTest extends AbstractServiceTest { config.setExpression("T - (100 - H) / 5"); - Output output = new Output(); + TimeSeriesOutput output = new TimeSeriesOutput(); output.setName("output"); - output.setType(OutputType.TIME_SERIES); config.setOutput(output); diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/CalculatedFieldServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/CalculatedFieldServiceTest.java index 0e20f188b1..d06a7d6355 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/CalculatedFieldServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/CalculatedFieldServiceTest.java @@ -28,11 +28,10 @@ import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentType; import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; -import org.thingsboard.server.common.data.cf.configuration.Output; -import org.thingsboard.server.common.data.cf.configuration.OutputType; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration; import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.TimeSeriesOutput; import org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates; import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.geofencing.ZoneGroupConfiguration; @@ -163,7 +162,7 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { .getMaxRelationLevelPerCfArgument(); // Zone-group argument (ATTRIBUTE) - ZoneGroupConfiguration zoneGroupConfiguration = new ZoneGroupConfiguration( "allowed", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); + ZoneGroupConfiguration zoneGroupConfiguration = new ZoneGroupConfiguration("allowed", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); var dynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration(); List levels = new ArrayList<>(); @@ -203,7 +202,7 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { cfg.setEntityCoordinates(entityCoordinates); // Zone-group argument (ATTRIBUTE) — make it DYNAMIC so scheduling is enabled - ZoneGroupConfiguration zoneGroupConfiguration = new ZoneGroupConfiguration( "allowed", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); + ZoneGroupConfiguration zoneGroupConfiguration = new ZoneGroupConfiguration("allowed", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); var dynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration(); dynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.FROM, EntityRelation.CONTAINS_TYPE))); zoneGroupConfiguration.setRefDynamicSourceConfiguration(dynamicSourceConfiguration); @@ -300,9 +299,8 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { config.setExpression("T - (100 - H) / 5"); - Output output = new Output(); + TimeSeriesOutput output = new TimeSeriesOutput(); output.setName("output"); - output.setType(OutputType.TIME_SERIES); config.setOutput(output); diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/CustomerServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/CustomerServiceTest.java index daa10e72e9..e69685f429 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/CustomerServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/CustomerServiceTest.java @@ -35,10 +35,9 @@ import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentType; -import org.thingsboard.server.common.data.cf.configuration.Output; -import org.thingsboard.server.common.data.cf.configuration.OutputType; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.TimeSeriesOutput; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; @@ -389,9 +388,8 @@ public class CustomerServiceTest extends AbstractServiceTest { config.setExpression("T - (100 - H) / 5"); - Output output = new Output(); + TimeSeriesOutput output = new TimeSeriesOutput(); output.setName("output"); - output.setType(OutputType.TIME_SERIES); config.setOutput(output); diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/DeviceServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/DeviceServiceTest.java index 32767043d7..25e813aac4 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/DeviceServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/DeviceServiceTest.java @@ -43,10 +43,9 @@ import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentType; -import org.thingsboard.server.common.data.cf.configuration.Output; -import org.thingsboard.server.common.data.cf.configuration.OutputType; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.TimeSeriesOutput; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.OtaPackageId; @@ -1243,9 +1242,8 @@ public class DeviceServiceTest extends AbstractServiceTest { config.setExpression("T - (100 - H) / 5"); - Output output = new Output(); + TimeSeriesOutput output = new TimeSeriesOutput(); output.setName("output"); - output.setType(OutputType.TIME_SERIES); config.setOutput(output); diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/service/MonitoringEntityService.java b/monitoring/src/main/java/org/thingsboard/monitoring/service/MonitoringEntityService.java index 062104ecd5..d2cf076516 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/service/MonitoringEntityService.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/service/MonitoringEntityService.java @@ -40,10 +40,9 @@ import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentType; -import org.thingsboard.server.common.data.cf.configuration.Output; -import org.thingsboard.server.common.data.cf.configuration.OutputType; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; import org.thingsboard.server.common.data.cf.configuration.ScriptCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.TimeSeriesOutput; import org.thingsboard.server.common.data.device.credentials.lwm2m.LwM2MBootstrapClientCredentials; import org.thingsboard.server.common.data.device.credentials.lwm2m.LwM2MDeviceCredentials; import org.thingsboard.server.common.data.device.credentials.lwm2m.NoSecBootstrapClientCredential; @@ -241,9 +240,7 @@ public class MonitoringEntityService { TEST_TELEMETRY_KEY, testDataArgument )); configuration.setExpression("return { \"" + TEST_CF_TELEMETRY_KEY + "\": " + TEST_TELEMETRY_KEY + " + \"-cf\" };"); - Output output = new Output(); - output.setType(OutputType.TIME_SERIES); - configuration.setOutput(output); + configuration.setOutput(new TimeSeriesOutput()); calculatedField.setConfiguration(configuration); calculatedField.setDebugMode(true); tbClient.saveCalculatedField(calculatedField); From 235fd1a27b6af484d0de88bbd1739c3e28706754 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Wed, 12 Nov 2025 12:03:19 +0200 Subject: [PATCH 29/56] Add isExpired endpoint --- .../server/controller/ApiKeyController.java | 20 ++++++++ .../controller/ApiKeyControllerTest.java | 48 +++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java b/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java index d9df135811..8b20c0beed 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java +++ b/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java @@ -136,6 +136,26 @@ public class ApiKeyController extends BaseController { return apiKeyService.saveApiKey(apiKey.getTenantId(), apiKey); } + + @ApiOperation(value = "Check if API key is expired (isApiKeyExpired)", + notes = "Returns true if the API key is expired, false otherwise. " + + "Referencing a non-existing ApiKey Id will cause a 'Not Found' error." + + AVAILABLE_FOR_ANY_AUTHORIZED_USER) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN','TENANT_ADMIN', 'CUSTOMER_USER')") + @GetMapping(value = "/apiKey/{id}/expired") + public boolean isApiKeyExpired( + @Parameter(description = API_KEY_ID_PARAM_DESCRIPTION, required = true) + @PathVariable UUID id) throws ThingsboardException { + ApiKeyId apiKeyId = new ApiKeyId(id); + ApiKey apiKey = checkApiKeyId(apiKeyId, Operation.READ); + + if (apiKey.getExpirationTime() > 0) { + return System.currentTimeMillis() > apiKey.getExpirationTime(); + } + + return false; + } + @ApiOperation(value = "Delete API key by ID (deleteApiKey)", notes = "Deletes the API key. Referencing non-existing ApiKey Id will cause an error." + AVAILABLE_FOR_ANY_AUTHORIZED_USER) @PreAuthorize("hasAnyAuthority('SYS_ADMIN','TENANT_ADMIN', 'CUSTOMER_USER')") diff --git a/application/src/test/java/org/thingsboard/server/controller/ApiKeyControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/ApiKeyControllerTest.java index 167839e7f3..0350b9acf4 100644 --- a/application/src/test/java/org/thingsboard/server/controller/ApiKeyControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/ApiKeyControllerTest.java @@ -119,6 +119,54 @@ public class ApiKeyControllerTest extends AbstractControllerTest { doDelete("/api/apiKey/" + savedApiKey.getId()).andExpect(status().isOk()); } + @Test + public void testIsApiKeyExpired() throws Exception { + doGet("/api/apiKey/" + UUID.randomUUID() + "/expired").andExpect(status().isNotFound()); + + ApiKeyInfo apiKeyInfo = constructApiKeyInfo("Test API key description", true); + doPost("/api/apiKey", apiKeyInfo, ApiKey.class); + + PageData pageData = doGetTypedWithPageLink("/api/apiKeys/" + tenantAdminUserId + "?", new TypeReference<>() {}, new PageLink(10, 0)); + Assert.assertEquals(1, pageData.getData().size()); + ApiKeyInfo savedApiKeyNoExpiration = pageData.getData().get(0); + + Boolean isExpiredNoExpiration = doGet("/api/apiKey/" + savedApiKeyNoExpiration.getId().getId() + "/expired", Boolean.class); + Assert.assertNotNull(isExpiredNoExpiration); + Assert.assertFalse(isExpiredNoExpiration); + + doDelete("/api/apiKey/" + savedApiKeyNoExpiration.getId()).andExpect(status().isOk()); + + ApiKeyInfo apiKeyInfoFutureExpiration = constructApiKeyInfo("Test API key future expiration", true); + long futureExpirationTime = System.currentTimeMillis() + 3600000; + apiKeyInfoFutureExpiration.setExpirationTime(futureExpirationTime); + doPost("/api/apiKey", apiKeyInfoFutureExpiration, ApiKey.class); + + PageData pageData2 = doGetTypedWithPageLink("/api/apiKeys/" + tenantAdminUserId + "?", new TypeReference<>() {}, new PageLink(10, 0)); + Assert.assertEquals(1, pageData2.getData().size()); + ApiKeyInfo savedApiKeyFuture = pageData2.getData().get(0); + + Boolean isExpiredFuture = doGet("/api/apiKey/" + savedApiKeyFuture.getId().getId() + "/expired", Boolean.class); + Assert.assertNotNull(isExpiredFuture); + Assert.assertFalse(isExpiredFuture); + + doDelete("/api/apiKey/" + savedApiKeyFuture.getId()).andExpect(status().isOk()); + + ApiKeyInfo apiKeyInfoPastExpiration = constructApiKeyInfo("Test API key past expiration", true); + long pastExpirationTime = System.currentTimeMillis() - 3600000; + apiKeyInfoPastExpiration.setExpirationTime(pastExpirationTime); + doPost("/api/apiKey", apiKeyInfoPastExpiration, ApiKey.class); + + PageData pageData3 = doGetTypedWithPageLink("/api/apiKeys/" + tenantAdminUserId + "?", new TypeReference<>() {}, new PageLink(10, 0)); + Assert.assertEquals(1, pageData3.getData().size()); + ApiKeyInfo savedApiKeyPast = pageData3.getData().get(0); + + Boolean isExpiredPast = doGet("/api/apiKey/" + savedApiKeyPast.getId().getId() + "/expired", Boolean.class); + Assert.assertNotNull(isExpiredPast); + Assert.assertTrue(isExpiredPast); + + doDelete("/api/apiKey/" + savedApiKeyPast.getId()).andExpect(status().isOk()); + } + @Test public void testDeleteApiKey() throws Exception { doDelete("/api/apiKey/" + UUID.randomUUID()).andExpect(status().isNotFound()); From a0496b612f014f50566f73de4a86162fc169860c Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Wed, 12 Nov 2025 12:50:20 +0200 Subject: [PATCH 30/56] Add isExpired for ApiKeyInfo --- .../server/controller/ApiKeyController.java | 20 -------- .../controller/ApiKeyControllerTest.java | 48 ------------------- .../server/common/data/pat/ApiKeyInfo.java | 12 +++++ 3 files changed, 12 insertions(+), 68 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java b/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java index 8b20c0beed..d9df135811 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java +++ b/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java @@ -136,26 +136,6 @@ public class ApiKeyController extends BaseController { return apiKeyService.saveApiKey(apiKey.getTenantId(), apiKey); } - - @ApiOperation(value = "Check if API key is expired (isApiKeyExpired)", - notes = "Returns true if the API key is expired, false otherwise. " + - "Referencing a non-existing ApiKey Id will cause a 'Not Found' error." + - AVAILABLE_FOR_ANY_AUTHORIZED_USER) - @PreAuthorize("hasAnyAuthority('SYS_ADMIN','TENANT_ADMIN', 'CUSTOMER_USER')") - @GetMapping(value = "/apiKey/{id}/expired") - public boolean isApiKeyExpired( - @Parameter(description = API_KEY_ID_PARAM_DESCRIPTION, required = true) - @PathVariable UUID id) throws ThingsboardException { - ApiKeyId apiKeyId = new ApiKeyId(id); - ApiKey apiKey = checkApiKeyId(apiKeyId, Operation.READ); - - if (apiKey.getExpirationTime() > 0) { - return System.currentTimeMillis() > apiKey.getExpirationTime(); - } - - return false; - } - @ApiOperation(value = "Delete API key by ID (deleteApiKey)", notes = "Deletes the API key. Referencing non-existing ApiKey Id will cause an error." + AVAILABLE_FOR_ANY_AUTHORIZED_USER) @PreAuthorize("hasAnyAuthority('SYS_ADMIN','TENANT_ADMIN', 'CUSTOMER_USER')") diff --git a/application/src/test/java/org/thingsboard/server/controller/ApiKeyControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/ApiKeyControllerTest.java index 0350b9acf4..167839e7f3 100644 --- a/application/src/test/java/org/thingsboard/server/controller/ApiKeyControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/ApiKeyControllerTest.java @@ -119,54 +119,6 @@ public class ApiKeyControllerTest extends AbstractControllerTest { doDelete("/api/apiKey/" + savedApiKey.getId()).andExpect(status().isOk()); } - @Test - public void testIsApiKeyExpired() throws Exception { - doGet("/api/apiKey/" + UUID.randomUUID() + "/expired").andExpect(status().isNotFound()); - - ApiKeyInfo apiKeyInfo = constructApiKeyInfo("Test API key description", true); - doPost("/api/apiKey", apiKeyInfo, ApiKey.class); - - PageData pageData = doGetTypedWithPageLink("/api/apiKeys/" + tenantAdminUserId + "?", new TypeReference<>() {}, new PageLink(10, 0)); - Assert.assertEquals(1, pageData.getData().size()); - ApiKeyInfo savedApiKeyNoExpiration = pageData.getData().get(0); - - Boolean isExpiredNoExpiration = doGet("/api/apiKey/" + savedApiKeyNoExpiration.getId().getId() + "/expired", Boolean.class); - Assert.assertNotNull(isExpiredNoExpiration); - Assert.assertFalse(isExpiredNoExpiration); - - doDelete("/api/apiKey/" + savedApiKeyNoExpiration.getId()).andExpect(status().isOk()); - - ApiKeyInfo apiKeyInfoFutureExpiration = constructApiKeyInfo("Test API key future expiration", true); - long futureExpirationTime = System.currentTimeMillis() + 3600000; - apiKeyInfoFutureExpiration.setExpirationTime(futureExpirationTime); - doPost("/api/apiKey", apiKeyInfoFutureExpiration, ApiKey.class); - - PageData pageData2 = doGetTypedWithPageLink("/api/apiKeys/" + tenantAdminUserId + "?", new TypeReference<>() {}, new PageLink(10, 0)); - Assert.assertEquals(1, pageData2.getData().size()); - ApiKeyInfo savedApiKeyFuture = pageData2.getData().get(0); - - Boolean isExpiredFuture = doGet("/api/apiKey/" + savedApiKeyFuture.getId().getId() + "/expired", Boolean.class); - Assert.assertNotNull(isExpiredFuture); - Assert.assertFalse(isExpiredFuture); - - doDelete("/api/apiKey/" + savedApiKeyFuture.getId()).andExpect(status().isOk()); - - ApiKeyInfo apiKeyInfoPastExpiration = constructApiKeyInfo("Test API key past expiration", true); - long pastExpirationTime = System.currentTimeMillis() - 3600000; - apiKeyInfoPastExpiration.setExpirationTime(pastExpirationTime); - doPost("/api/apiKey", apiKeyInfoPastExpiration, ApiKey.class); - - PageData pageData3 = doGetTypedWithPageLink("/api/apiKeys/" + tenantAdminUserId + "?", new TypeReference<>() {}, new PageLink(10, 0)); - Assert.assertEquals(1, pageData3.getData().size()); - ApiKeyInfo savedApiKeyPast = pageData3.getData().get(0); - - Boolean isExpiredPast = doGet("/api/apiKey/" + savedApiKeyPast.getId().getId() + "/expired", Boolean.class); - Assert.assertNotNull(isExpiredPast); - Assert.assertTrue(isExpiredPast); - - doDelete("/api/apiKey/" + savedApiKeyPast.getId()).andExpect(status().isOk()); - } - @Test public void testDeleteApiKey() throws Exception { doDelete("/api/apiKey/" + UUID.randomUUID()).andExpect(status().isNotFound()); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/pat/ApiKeyInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/pat/ApiKeyInfo.java index 3bfb53ca0f..aea83e5dca 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/pat/ApiKeyInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/pat/ApiKeyInfo.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.pat; +import com.fasterxml.jackson.annotation.JsonProperty; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; @@ -53,6 +54,17 @@ public class ApiKeyInfo extends BaseData implements HasTenantId { @Schema(description = "Enabled/disabled api key.", example = "true") private boolean enabled; + @JsonProperty(access = JsonProperty.Access.READ_ONLY) + @Schema(description = "Indicates if the api key is expired based on current time. Returns false if expirationTime is 0 (no expiry).", + example = "false", + accessMode = Schema.AccessMode.READ_ONLY) + public boolean isExpired() { + if (expirationTime == 0) { + return false; + } + return System.currentTimeMillis() > expirationTime; + } + @Schema(description = "JSON object with the Api Key Id. " + "Specify this field to update the Api Key. " + "Referencing non-existing Api Key Id will cause error. " + From 737ee2cd02b4111ab4e5b4a64f2c6a9ae3e6a07e Mon Sep 17 00:00:00 2001 From: deaflynx Date: Wed, 12 Nov 2025 17:24:29 +0200 Subject: [PATCH 31/56] UI: Added feature API keys management. --- ui-ngx/src/app/core/http/api-key.service.ts | 54 +++++ .../api-key/api-keys-table-config.ts | 207 ++++++++++++++++++ .../api-key/api-keys-table.component.html | 20 ++ .../api-key/api-keys-table.component.scss | 45 ++++ .../api-key/api-keys-table.component.ts | 80 +++++++ .../dialog/add-api-key-dialog.component.html | 84 +++++++ .../dialog/add-api-key-dialog.component.scss | 39 ++++ .../dialog/add-api-key-dialog.component.ts | 111 ++++++++++ .../api-key-generated-dialog.component.html | 46 ++++ .../api-key-generated-dialog.component.scss | 85 +++++++ .../api-key-generated-dialog.component.ts | 55 +++++ .../api-keys-table-dialog.component.html | 35 +++ .../api-keys-table-dialog.component.scss | 36 +++ .../dialog/api-keys-table-dialog.component.ts | 47 ++++ ...t-api-key-description-panel.component.html | 41 ++++ ...t-api-key-description-panel.component.scss | 41 ++++ ...dit-api-key-description-panel.component.ts | 61 ++++++ .../home/components/home-components.module.ts | 21 ++ .../pages/security/security.component.html | 14 ++ .../home/pages/security/security.component.ts | 14 ++ .../home/pages/user/user-tabs.component.html | 4 + .../src/app/shared/models/api-key.models.ts | 34 +++ ui-ngx/src/app/shared/models/constants.ts | 1 + .../app/shared/models/entity-type.models.ts | 23 +- ui-ngx/src/app/shared/models/id/api-key-id.ts | 26 +++ .../app/shared/pipe/date-expiration.pipe.ts | 42 ++++ ui-ngx/src/app/shared/shared.module.ts | 3 + .../assets/locale/locale.constant-en_US.json | 34 +++ 28 files changed, 1301 insertions(+), 2 deletions(-) create mode 100644 ui-ngx/src/app/core/http/api-key.service.ts create mode 100644 ui-ngx/src/app/modules/home/components/api-key/api-keys-table-config.ts create mode 100644 ui-ngx/src/app/modules/home/components/api-key/api-keys-table.component.html create mode 100644 ui-ngx/src/app/modules/home/components/api-key/api-keys-table.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/api-key/api-keys-table.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/api-key/components/dialog/add-api-key-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/api-key/components/dialog/add-api-key-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/api-key/components/dialog/add-api-key-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/api-key/components/dialog/api-key-generated-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/api-key/components/dialog/api-key-generated-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/api-key/components/dialog/api-key-generated-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/api-key/components/dialog/api-keys-table-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/api-key/components/dialog/api-keys-table-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/api-key/components/dialog/api-keys-table-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/api-key/components/dialog/edit-api-key-description-panel.component.html create mode 100644 ui-ngx/src/app/modules/home/components/api-key/components/dialog/edit-api-key-description-panel.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/api-key/components/dialog/edit-api-key-description-panel.component.ts create mode 100644 ui-ngx/src/app/shared/models/api-key.models.ts create mode 100644 ui-ngx/src/app/shared/models/id/api-key-id.ts create mode 100644 ui-ngx/src/app/shared/pipe/date-expiration.pipe.ts diff --git a/ui-ngx/src/app/core/http/api-key.service.ts b/ui-ngx/src/app/core/http/api-key.service.ts new file mode 100644 index 0000000000..e2789dfab7 --- /dev/null +++ b/ui-ngx/src/app/core/http/api-key.service.ts @@ -0,0 +1,54 @@ +/// +/// Copyright © 2016-2025 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. +/// + +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { defaultHttpOptionsFromConfig, RequestConfig } from '@core/http/http-utils'; +import { Observable } from 'rxjs'; +import { PageLink } from '@shared/models/page/page-link'; +import { PageData } from '@shared/models/page/page-data'; +import { ApiKeyInfo, ApiKey } from '@shared/models/api-key.models'; + +@Injectable({ + providedIn: 'root' +}) +export class ApiKeyService { + + constructor( + private http: HttpClient + ) { + } + + public saveApiKey(apiKey: ApiKeyInfo, config?: RequestConfig): Observable { + return this.http.post('/api/apiKey', apiKey, defaultHttpOptionsFromConfig(config)); + } + + public deleteApiKey(id: string, config?: RequestConfig): Observable { + return this.http.delete(`/api/apiKey/${id}`, defaultHttpOptionsFromConfig(config)); + } + + public updateApiKeyDescription(id: string, description: string, config?: RequestConfig): Observable { + return this.http.put(`/api/apiKey/${id}/description`, description, defaultHttpOptionsFromConfig(config)); + } + + public enableApiKey(id: string, enabledValue: boolean, config?: RequestConfig): Observable { + return this.http.put(`/api/apiKey/${id}/enabled/${enabledValue}`, defaultHttpOptionsFromConfig(config)); + } + + public getUserApiKeys(userId: string, pageLink: PageLink, config?: RequestConfig): Observable> { + return this.http.get>(`/api/apiKeys/${userId}${pageLink.toQuery()}`, defaultHttpOptionsFromConfig(config)); + } +} diff --git a/ui-ngx/src/app/modules/home/components/api-key/api-keys-table-config.ts b/ui-ngx/src/app/modules/home/components/api-key/api-keys-table-config.ts new file mode 100644 index 0000000000..daf4dbf7b2 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/api-key/api-keys-table-config.ts @@ -0,0 +1,207 @@ +/// +/// Copyright © 2016-2025 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. +/// + +import { + DateEntityTableColumn, + EntityTableColumn, + EntityTableConfig +} from '@home/models/entity/entities-table-config.models'; +import { EntityType, EntityTypeResource, entityTypeTranslations } from '@shared/models/entity-type.models'; +import { Direction } from '@shared/models/page/sort-order'; +import { TranslateService } from '@ngx-translate/core'; +import { MatDialog } from '@angular/material/dialog'; +import { Injectable, Renderer2, ViewContainerRef } from '@angular/core'; +import { DatePipe } from '@angular/common'; +import { Observable } from 'rxjs'; +import { ApiKeyInfo, ApiKey } from '@shared/models/api-key.models'; +import { ApiKeyService } from '@core/http/api-key.service'; +import { CustomTranslatePipe } from '@shared/pipe/custom-translate.pipe'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { map } from 'rxjs/operators'; +import { UserId } from '@shared/models/id/user-id'; +import { AddApiKeyDialogComponent } from '@home/components/api-key/components/dialog/add-api-key-dialog.component'; +import { + EditApiKeyDescriptionPanelComponent +} from '@home/components/api-key/components/dialog/edit-api-key-description-panel.component'; +import { ApiKeysTableDialogData } from '@home/components/api-key/components/dialog/api-keys-table-dialog.component'; +import { + ApiKeyGeneratedDialogComponent, ApiKeyGeneratedDialogData +} from '@home/components/api-key/components/dialog/api-key-generated-dialog.component'; + +@Injectable() +export class ApiKeysTableConfig extends EntityTableConfig { + + constructor( + private apiKeyService: ApiKeyService, + private translate: TranslateService, + private customTranslate: CustomTranslatePipe, + private dialog: MatDialog, + private datePipe: DatePipe, + private popoverService: TbPopoverService, + private renderer: Renderer2, + private viewContainerRef: ViewContainerRef, + private userId: UserId, + ) { + super(); + + this.entityType = EntityType.API_KEY; + this.detailsPanelEnabled = false; + this.addAsTextButton = true; + this.pageMode = false; + + this.entityTranslations = entityTypeTranslations.get(EntityType.API_KEY); + this.entityResources = {} as EntityTypeResource; + this.tableTitle = this.translate.instant('api-key.api-keys'); + + this.entitiesFetchFunction = pageLink => this.apiKeyService.getUserApiKeys(this.userId.id, pageLink); + this.addEntity = () => this.addApiKey(); + + this.deleteEntityTitle = entity => this.translate.instant('api-key.delete-api-key-title', {name: entity.description}); + this.deleteEntityContent = () => this.translate.instant('api-key.delete-api-key-text'); + this.deleteEntitiesTitle = count => this.translate.instant('api-key.delete-api-keys-title', {count}); + this.deleteEntitiesContent = () => this.translate.instant('api-key.delete-api-keys-text'); + this.deleteEntity = id => this.apiKeyService.deleteApiKey(id.id); + + this.cellActionDescriptors = [{ + name: '', + nameFunction: (entity) => + this.translate.instant(entity.enabled ? 'api-key.disable' : 'api-key.enable'), + icon: 'mdi:toggle-switch', + isEnabled: (entity) => !entity.expired, + iconFunction: (entity) => entity.enabled ? 'mdi:toggle-switch' : 'mdi:toggle-switch-off-outline', + onAction: ($event, entity) => this.toggleEnableMode($event, entity) + }]; + + this.defaultSortOrder = {property: 'createdTime', direction: Direction.DESC}; + + this.columns.push( + new DateEntityTableColumn('createdTime', 'common.created-time', this.datePipe, '170px'), + new EntityTableColumn('description', 'api-key.description', '100%', + (entity) => this.customTranslate.transform(entity?.description), () => ({}), true, () => ({}), + (entity) => entity?.description.length > 80 ? this.customTranslate.transform(entity.description) : undefined, false, + { + name: this.translate.instant('api-key.edit-description'), + icon: 'edit', + isEnabled: () => true, + onAction: ($event, entity) => this.updateApiKeyDescription($event, entity) + }), + new EntityTableColumn('active', 'api-key.status', '80px', + entity => this.apiKeyStatus(entity), entity => this.apiKeyStatusStyle(entity), false), + new EntityTableColumn('expirationTime', 'api-key.expiration-time', '120px', + (entity) => entity.expirationTime != 0 ? + this.datePipe.transform(entity.expirationTime, 'dd/MM/yyyy, HH:mm') : + this.translate.instant('api-key.expiration-time-never'), + ), + ); + } + + private addApiKey(): Observable { + return this.dialog.open(AddApiKeyDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + userId: this.userId + } + }).afterClosed().pipe(map(res => { + if (res) { + this.apiKeyGenerated(res); + } else { + return null; + } + })); + } + + private apiKeyGenerated(apiKey: ApiKey) { + this.dialog.open(ApiKeyGeneratedDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + apiKey + } + }).afterClosed() + .subscribe(() => { + this.updateData(); + }); + } + + + private toggleEnableMode($event: Event, entity: ApiKeyInfo): void { + if ($event) { + $event.stopPropagation(); + } + this.apiKeyService.enableApiKey(entity.id.id, !entity.enabled, {ignoreLoading: true}) + .subscribe( + () => this.updateData() + ); + } + + private apiKeyStatus(apiKey: ApiKeyInfo): string { + let translateKey = 'api-key.status-active'; + let backgroundColor = 'rgba(25, 128, 56, 0.08)'; + if (apiKey.expired) { + translateKey = 'api-key.status-expired'; + backgroundColor = 'rgba(0, 0, 0, 0.04)'; + } else if (!apiKey.enabled) { + translateKey = 'api-key.status-inactive'; + backgroundColor = 'rgba(209, 39, 48, 0.08)'; + } + return `
+ ${this.translate.instant(translateKey)} +
`; + } + + private apiKeyStatusStyle(apiKey: ApiKeyInfo): object { + const styleObj = { + fontSize: '14px', + color: '#198038', + cursor: 'pointer' + }; + if (apiKey.expired) { + styleObj.color = 'rgba(0, 0, 0, 0.54)'; + } else if (!apiKey.enabled) { + styleObj.color = '#d12730'; + } + return styleObj; + } + + private updateApiKeyDescription($event: Event, entity: ApiKeyInfo) { + if ($event) { + $event.stopPropagation(); + } + const trigger = ($event.target || $event.srcElement || $event.currentTarget) as Element; + if (this.popoverService.hasPopover(trigger)) { + this.popoverService.hidePopover(trigger); + } else { + const editSecretDescriptionPanelPopover = this.popoverService.displayPopover({ + trigger, + renderer: this.renderer, + componentType: EditApiKeyDescriptionPanelComponent, + hostView: this.viewContainerRef, + preferredPlacement: ['right', 'bottom', 'top'], + context: { + apiKeyId: entity.id.id, + description: entity.description + }, + isModal: true + }); + editSecretDescriptionPanelPopover.tbComponentRef.instance.descriptionApplied.subscribe(() => { + editSecretDescriptionPanelPopover.hide(); + this.updateData(); + }); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/api-key/api-keys-table.component.html b/ui-ngx/src/app/modules/home/components/api-key/api-keys-table.component.html new file mode 100644 index 0000000000..f469fc2f91 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/api-key/api-keys-table.component.html @@ -0,0 +1,20 @@ + +@if (apiKeysTableConfig) { + +} diff --git a/ui-ngx/src/app/modules/home/components/api-key/api-keys-table.component.scss b/ui-ngx/src/app/modules/home/components/api-key/api-keys-table.component.scss new file mode 100644 index 0000000000..a6e1ac2675 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/api-key/api-keys-table.component.scss @@ -0,0 +1,45 @@ +/** + * Copyright © 2016-2025 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. + */ +@import '../../src/scss/constants.scss'; +@import '../../src/scss/mixins'; + +:host ::ng-deep { + tb-entities-table { + .mat-drawer-container { + background-color: white; + + mat-cell.mat-column-description { + white-space: nowrap; + .mat-mdc-icon-button { + vertical-align: middle; + margin-left: 8px; + @include tb-mat-icon-button-size(32); + .mat-icon { + @include tb-mat-icon-size(20); + } + } + span { + display: inline-block; + max-width: 40ch; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: middle; + pointer-events: none; + } + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/api-key/api-keys-table.component.ts b/ui-ngx/src/app/modules/home/components/api-key/api-keys-table.component.ts new file mode 100644 index 0000000000..1240a5875d --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/api-key/api-keys-table.component.ts @@ -0,0 +1,80 @@ +/// +/// Copyright © 2016-2025 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. +/// + +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + effect, + input, + Renderer2, + ViewChild, + ViewContainerRef, +} from '@angular/core'; +import { EntitiesTableComponent } from '@home/components/entity/entities-table.component'; +import { TranslateService } from '@ngx-translate/core'; +import { MatDialog } from '@angular/material/dialog'; +import { DatePipe } from '@angular/common'; +import { ApiKeysTableConfig } from '@home/components/api-key/api-keys-table-config'; +import { ApiKeyService } from '@core/http/api-key.service'; +import { CustomTranslatePipe } from '@shared/pipe/custom-translate.pipe'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { UserId } from '@shared/models/id/user-id'; + +@Component({ + selector: 'tb-api-keys-table', + templateUrl: './api-keys-table.component.html', + styleUrls: ['./api-keys-table.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ApiKeysTableComponent { + + @ViewChild(EntitiesTableComponent, {static: true}) entitiesTable: EntitiesTableComponent; + + active = input(); + userId = input(); + + apiKeysTableConfig: ApiKeysTableConfig; + + constructor( + private apiKeyService: ApiKeyService, + private translate: TranslateService, + private customTranslate: CustomTranslatePipe, + private dialog: MatDialog, + private datePipe: DatePipe, + private cd: ChangeDetectorRef, + private popoverService: TbPopoverService, + private renderer: Renderer2, + private viewContainerRef: ViewContainerRef, + ) { + effect(() => { + if (this.active()) { + this.apiKeysTableConfig = new ApiKeysTableConfig( + this.apiKeyService, + this.translate, + this.customTranslate, + this.dialog, + this.datePipe, + this.popoverService, + this.renderer, + this.viewContainerRef, + this.userId(), + ); + this.cd.markForCheck(); + } + }); + } +} diff --git a/ui-ngx/src/app/modules/home/components/api-key/components/dialog/add-api-key-dialog.component.html b/ui-ngx/src/app/modules/home/components/api-key/components/dialog/add-api-key-dialog.component.html new file mode 100644 index 0000000000..f1db112383 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/api-key/components/dialog/add-api-key-dialog.component.html @@ -0,0 +1,84 @@ + +
+ +

{{ 'api-key.generate-title' | translate }}

+ +
+ +
+ @if (isLoading$ | async) { + + } @else { +
+ } +
+
+
+ api-key.generate-text +
+ + api-key.description + + + + {{ 'api-key.enable' | translate }} + +
+ + + + {{ value | dateExpiration }} + + {{'api-key.expiration-time-never' | translate}} + {{'api-key.expiration-time-custom' | translate}} + + + @if (isCustomExpirationTime()) { + + api-key.date + + + + + } +
+
+
+
+ + +
+
+ diff --git a/ui-ngx/src/app/modules/home/components/api-key/components/dialog/add-api-key-dialog.component.scss b/ui-ngx/src/app/modules/home/components/api-key/components/dialog/add-api-key-dialog.component.scss new file mode 100644 index 0000000000..cbed02a70e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/api-key/components/dialog/add-api-key-dialog.component.scss @@ -0,0 +1,39 @@ +/** + * Copyright © 2016-2025 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. + */ +@import '../../src/scss/constants'; + +:host{ + form { + width: 700px; + } + .api-key-text { + position: relative; + padding: 8px 16px 8px 16px; + &::before { + content: ''; + position: absolute; + inset: 0; + background-color: $tb-primary-color; + border-radius: 6px; + opacity: 0.04; + } + + span { + font-size: 12px; + color: rgba(0, 0, 0, 0.54); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/api-key/components/dialog/add-api-key-dialog.component.ts b/ui-ngx/src/app/modules/home/components/api-key/components/dialog/add-api-key-dialog.component.ts new file mode 100644 index 0000000000..b5611bca5a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/api-key/components/dialog/add-api-key-dialog.component.ts @@ -0,0 +1,111 @@ +/// +/// Copyright © 2016-2025 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. +/// + + +import { Component, OnInit, Inject } from '@angular/core'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { Router } from '@angular/router'; +import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { FormBuilder, Validators, UntypedFormGroup } from '@angular/forms'; +import { deepTrim } from '@core/utils'; +import { ApiKeyService } from '@core/http/api-key.service'; +import { ApiKeyInfo } from '@shared/models/api-key.models'; +import { ApiKeysTableDialogData } from '@home/components/api-key/components/dialog/api-keys-table-dialog.component'; +import { DAY } from '@shared/models/time/time.models'; + +@Component({ + selector: 'tb-add-api-key-dialog', + templateUrl: './add-api-key-dialog.component.html', + styleUrls: ['./add-api-key-dialog.component.scss'] +}) +export class AddApiKeyDialogComponent extends DialogComponent implements OnInit{ + + apiKeyForm: UntypedFormGroup; + + readonly startDate = new Date(); + readonly expirationDates = [7, 30, 60, 90].map(days => days * DAY); + + private defaultExpirationDate = this.expirationDates[1]; + + constructor( + protected store: Store, + protected router: Router, + public dialogRef: MatDialogRef, + private fb: FormBuilder, + private apiKeyService: ApiKeyService, + @Inject(MAT_DIALOG_DATA) public data: ApiKeysTableDialogData, + ) { + super(store, router, dialogRef); + } + + ngOnInit() { + this.apiKeyForm = this.fb.group({ + description: [{value: null, disabled: false}, [Validators.required]], + enabled: [{value: true, disabled: false}, []], + expirationTime: [{value: this.defaultExpirationDate, disabled: false}, [Validators.required]], + customExpirationTime: [{value: null, disabled: true}, []], + }); + } + + close(): void { + this.dialogRef.close(null); + } + + add(): void { + const formValue = this.apiKeyForm.value; + const userId = this.data.userId; + const expirationTime = this.calcExpirationTime(); + const apiKey = { + ...deepTrim(formValue), + expirationTime, + userId, + } as ApiKeyInfo; + this.apiKeyService.saveApiKey(apiKey).subscribe( + (res) => { + this.dialogRef.close(res); + } + ); + } + + isCustomExpirationTime() { + return this.apiKeyForm.value?.expirationTime === 'custom'; + } + + onExpirationDateChange() { + const customExpirationTimeControl = this.apiKeyForm.get('customExpirationTime'); + if (this.isCustomExpirationTime()) { + customExpirationTimeControl.enable(); + } else { + customExpirationTimeControl.disable(); + } + customExpirationTimeControl.updateValueAndValidity(); + } + + private calcExpirationTime(): number { + const expirationTimeValue = this.apiKeyForm.get('expirationTime').value; + let value: number; + if (this.isCustomExpirationTime()) { + value = this.apiKeyForm.get('customExpirationTime').value.getTime(); + } else if (expirationTimeValue === 'never') { + value = 0; + } else { + value = expirationTimeValue + Date.now(); + } + return value; + } +} diff --git a/ui-ngx/src/app/modules/home/components/api-key/components/dialog/api-key-generated-dialog.component.html b/ui-ngx/src/app/modules/home/components/api-key/components/dialog/api-key-generated-dialog.component.html new file mode 100644 index 0000000000..a533bea1f7 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/api-key/components/dialog/api-key-generated-dialog.component.html @@ -0,0 +1,46 @@ + + +

{{ 'api-key.generated-title' | translate }}

+ + +
+
+ api-key.generated-text +
+ +
+
api-key.generated-command-title
+ +
+
+
+
+ +
diff --git a/ui-ngx/src/app/modules/home/components/api-key/components/dialog/api-key-generated-dialog.component.scss b/ui-ngx/src/app/modules/home/components/api-key/components/dialog/api-key-generated-dialog.component.scss new file mode 100644 index 0000000000..3ea6dcb614 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/api-key/components/dialog/api-key-generated-dialog.component.scss @@ -0,0 +1,85 @@ +/** + * Copyright © 2016-2025 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. + */ +:host{ + display: block; + width: 600px; + max-width: 100%; + color: rgba(0, 0, 0, 0.76); +} + +:host ::ng-deep { + .tb-markdown-view { + .tb-command-code { + .code-wrapper { + padding: 0; + pre[class*=language-] { + margin: 0; + background: #F3F6FA; + border-color: #305680; + padding-right: 38px; + padding-bottom: 4px; + min-height: 42px; + scrollbar-width: thin; + + &::-webkit-scrollbar { + width: 4px; + height: 4px; + } + } + } + button.clipboard-btn { + right: -2px; + p { + color: #305680; + } + p, div { + background-color: #F3F6FA; + } + div { + img { + display: none; + } + &:after { + content: ""; + position: initial; + display: block; + width: 18px; + height: 18px; + background: #305680; + mask-image: url(/assets/copy-code-icon.svg); + -webkit-mask-image: url(/assets/copy-code-icon.svg); + mask-repeat: no-repeat; + -webkit-mask-repeat: no-repeat; + } + } + } + } + } + .mdc-button__label > span { + .mat-icon { + vertical-align: text-bottom; + box-sizing: initial; + } + } + + .tabs-icon { + margin-right: 8px; + } + + .tb-form-panel.tb-tab-body { + padding: 16px 0 0; + } +} diff --git a/ui-ngx/src/app/modules/home/components/api-key/components/dialog/api-key-generated-dialog.component.ts b/ui-ngx/src/app/modules/home/components/api-key/components/dialog/api-key-generated-dialog.component.ts new file mode 100644 index 0000000000..034b1fcca5 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/api-key/components/dialog/api-key-generated-dialog.component.ts @@ -0,0 +1,55 @@ +/// +/// Copyright © 2016-2025 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. +/// + +import { Component, Inject } from '@angular/core'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { Router } from '@angular/router'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { userInfoCommand, ApiKey } from '@shared/models/api-key.models'; + +export interface ApiKeyGeneratedDialogData { + apiKey: ApiKey; +} + +@Component({ + selector: 'tb-api-key-generated-dialog', + templateUrl: './api-key-generated-dialog.component.html', + styleUrls: ['api-key-generated-dialog.component.scss'] +}) +export class ApiKeyGeneratedDialogComponent extends DialogComponent { + + apiKeyCommand = userInfoCommand(this.data.apiKey.value); + + constructor(protected store: Store, + protected router: Router, + protected dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: ApiKeyGeneratedDialogData) { + super(store, router, dialogRef); + } + + close(): void { + this.dialogRef.close(null); + } + + createMarkDownCommand(command: string): string { + return '```bash\n' + + command + + '{:copy-code}\n' + + '```'; + } +} diff --git a/ui-ngx/src/app/modules/home/components/api-key/components/dialog/api-keys-table-dialog.component.html b/ui-ngx/src/app/modules/home/components/api-key/components/dialog/api-keys-table-dialog.component.html new file mode 100644 index 0000000000..aaf261efb5 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/api-key/components/dialog/api-keys-table-dialog.component.html @@ -0,0 +1,35 @@ + + +

{{ 'api-key.manage-api-keys' | translate }}

+ +
+ +
+ +
+ +
diff --git a/ui-ngx/src/app/modules/home/components/api-key/components/dialog/api-keys-table-dialog.component.scss b/ui-ngx/src/app/modules/home/components/api-key/components/dialog/api-keys-table-dialog.component.scss new file mode 100644 index 0000000000..2de6ec9c16 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/api-key/components/dialog/api-keys-table-dialog.component.scss @@ -0,0 +1,36 @@ +/** + * Copyright © 2016-2025 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. + */ + +:host ::ng-deep { + tb-api-keys-table { + tb-entities-table { + .tb-absolute-fill { + position: relative; + } + .table-container { + width: 1000px; + min-height: 625px; + max-height: 625px; + } + .no-data-found { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/api-key/components/dialog/api-keys-table-dialog.component.ts b/ui-ngx/src/app/modules/home/components/api-key/components/dialog/api-keys-table-dialog.component.ts new file mode 100644 index 0000000000..900fdad9a1 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/api-key/components/dialog/api-keys-table-dialog.component.ts @@ -0,0 +1,47 @@ +/// +/// Copyright © 2016-2025 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. +/// + + +import { Component, Inject } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { Router } from '@angular/router'; +import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { UserId } from '@shared/models/id/user-id'; + +export interface ApiKeysTableDialogData { + userId: UserId; +} + +@Component({ + selector: 'tb-api-keys-table-dialog', + templateUrl: './api-keys-table-dialog.component.html', + styleUrls: ['api-keys-table-dialog.component.scss'] +}) +export class ApiKeysTableDialogComponent { + + constructor( + protected store: Store, + protected router: Router, + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: ApiKeysTableDialogData, + ) { + } + + close(): void { + this.dialogRef.close(null); + } +} diff --git a/ui-ngx/src/app/modules/home/components/api-key/components/dialog/edit-api-key-description-panel.component.html b/ui-ngx/src/app/modules/home/components/api-key/components/dialog/edit-api-key-description-panel.component.html new file mode 100644 index 0000000000..11f9e0e657 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/api-key/components/dialog/edit-api-key-description-panel.component.html @@ -0,0 +1,41 @@ + + +
+
{{ 'api-key.edit-description' | translate }}
+ + api-key.description + + {{input.value?.length || 0}}/255 + +
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/api-key/components/dialog/edit-api-key-description-panel.component.scss b/ui-ngx/src/app/modules/home/components/api-key/components/dialog/edit-api-key-description-panel.component.scss new file mode 100644 index 0000000000..607339a886 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/api-key/components/dialog/edit-api-key-description-panel.component.scss @@ -0,0 +1,41 @@ +/** + * Copyright © 2016-2025 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. + */ + + +.tb-edit-api-key-description-panel { + --mdc-outlined-text-field-outline-color: rgba(0,0,0,0.12); + + width: 400px; + max-width: 90vw; + display: flex; + flex-direction: column; + gap: 16px; + .tb-edit-api-key-description-title { + font-size: 16px; + font-weight: 500; + line-height: 24px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.87); + } + .tb-edit-api-key-description-panel-buttons { + height: 40px; + display: flex; + flex-direction: row; + gap: 16px; + justify-content: flex-end; + align-items: flex-end; + } +} diff --git a/ui-ngx/src/app/modules/home/components/api-key/components/dialog/edit-api-key-description-panel.component.ts b/ui-ngx/src/app/modules/home/components/api-key/components/dialog/edit-api-key-description-panel.component.ts new file mode 100644 index 0000000000..54f709f42f --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/api-key/components/dialog/edit-api-key-description-panel.component.ts @@ -0,0 +1,61 @@ +/// +/// Copyright © 2016-2025 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. +/// + + +import { Component, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core'; +import { FormBuilder } from '@angular/forms'; +import { TbPopoverComponent } from '@shared/components/popover.component'; +import { ApiKeyService } from '@core/http/api-key.service'; + +@Component({ + selector: 'tb-edit-api-key-description-panel', + templateUrl: './edit-api-key-description-panel.component.html', + styleUrls: ['./edit-api-key-description-panel.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class EditApiKeyDescriptionPanelComponent implements OnInit { + + @Input() + apiKeyId: string; + + @Input() + description: string; + + @Output() + descriptionApplied = new EventEmitter(); + + descriptionFormControl = this.fb.control(null); + + constructor(private fb: FormBuilder, + private popover: TbPopoverComponent, + private apiKeyService: ApiKeyService) {} + + ngOnInit(): void { + this.descriptionFormControl.setValue(this.description, {emitEvent: false}); + } + + cancel() { + this.popover.hide(); + } + + applyDescription() { + const description = this.descriptionFormControl.value.trim(); + this.apiKeyService.updateApiKeyDescription(this.apiKeyId, description).subscribe(() => { + this.descriptionApplied.emit(description); + }); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/home-components.module.ts b/ui-ngx/src/app/modules/home/components/home-components.module.ts index c74a0475e5..365b5fa82a 100644 --- a/ui-ngx/src/app/modules/home/components/home-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/home-components.module.ts @@ -197,6 +197,17 @@ import { import { CalculatedFieldsModule } from '@home/components/calculated-fields/calculated-field.module'; import { AlarmRuleModule } from "@home/components/alarm-rules/alarm-rule.module"; import { AlarmRulesTableComponent } from "@home/components/alarm-rules/alarm-rules-table.component"; +import { ApiKeysTableComponent } from '@home/components/api-key/api-keys-table.component'; +import { AddApiKeyDialogComponent } from '@home/components/api-key/components/dialog/add-api-key-dialog.component'; +import { + EditApiKeyDescriptionPanelComponent +} from '@home/components/api-key/components/dialog/edit-api-key-description-panel.component'; +import { + ApiKeyGeneratedDialogComponent +} from '@home/components/api-key/components/dialog/api-key-generated-dialog.component'; +import { + ApiKeysTableDialogComponent +} from '@home/components/api-key/components/dialog/api-keys-table-dialog.component'; @NgModule({ declarations: @@ -348,6 +359,11 @@ import { AlarmRulesTableComponent } from "@home/components/alarm-rules/alarm-rul AIModelDialogComponent, ResourcesDialogComponent, ResourcesLibraryComponent, + ApiKeysTableComponent, + ApiKeysTableDialogComponent, + AddApiKeyDialogComponent, + EditApiKeyDescriptionPanelComponent, + ApiKeyGeneratedDialogComponent, ], imports: [ CommonModule, @@ -494,6 +510,11 @@ import { AlarmRulesTableComponent } from "@home/components/alarm-rules/alarm-rul AIModelDialogComponent, ResourcesDialogComponent, ResourcesLibraryComponent, + ApiKeysTableComponent, + ApiKeysTableDialogComponent, + AddApiKeyDialogComponent, + EditApiKeyDescriptionPanelComponent, + ApiKeyGeneratedDialogComponent, ], providers: [ WidgetComponentService, diff --git a/ui-ngx/src/app/modules/home/pages/security/security.component.html b/ui-ngx/src/app/modules/home/pages/security/security.component.html index 5b661963c6..d8d1453f9b 100644 --- a/ui-ngx/src/app/modules/home/pages/security/security.component.html +++ b/ui-ngx/src/app/modules/home/pages/security/security.component.html @@ -30,6 +30,20 @@ + +
+ + api-key.api-keys + + +
+
diff --git a/ui-ngx/src/app/modules/home/pages/security/security.component.ts b/ui-ngx/src/app/modules/home/pages/security/security.component.ts index 32094d9677..026d798e36 100644 --- a/ui-ngx/src/app/modules/home/pages/security/security.component.ts +++ b/ui-ngx/src/app/modules/home/pages/security/security.component.ts @@ -52,6 +52,9 @@ import { isDefinedAndNotNull, isEqual } from '@core/utils'; import { AuthService } from '@core/auth/auth.service'; import { UserPasswordPolicy } from '@shared/models/settings.models'; import { MatCheckboxChange } from '@angular/material/checkbox'; +import { + ApiKeysTableDialogComponent, ApiKeysTableDialogData +} from '@home/components/api-key/components/dialog/api-keys-table-dialog.component'; @Component({ selector: 'tb-security', @@ -384,4 +387,15 @@ export class SecurityComponent extends PageComponent implements OnInit, OnDestro newPassword2: '' }); } + + openApiKeysTable() { + this.dialog.open( + ApiKeysTableDialogComponent, { + disableClose: false, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + userId: this.user.id, + } + }).afterClosed().subscribe(); + } } diff --git a/ui-ngx/src/app/modules/home/pages/user/user-tabs.component.html b/ui-ngx/src/app/modules/home/pages/user/user-tabs.component.html index 387a46952f..79a7a51b79 100644 --- a/ui-ngx/src/app/modules/home/pages/user/user-tabs.component.html +++ b/ui-ngx/src/app/modules/home/pages/user/user-tabs.component.html @@ -40,3 +40,7 @@ label="{{ 'audit-log.audit-logs' | translate }}" #auditLogsTab="matTab"> + + + diff --git a/ui-ngx/src/app/shared/models/api-key.models.ts b/ui-ngx/src/app/shared/models/api-key.models.ts new file mode 100644 index 0000000000..e3950cc5dd --- /dev/null +++ b/ui-ngx/src/app/shared/models/api-key.models.ts @@ -0,0 +1,34 @@ +/// +/// Copyright © 2016-2025 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. +/// + +import { BaseData } from '@shared/models/base-data'; +import { HasTenantId } from '@shared/models/entity.models'; +import { ApiKeyId } from '@shared/models/id/api-key-id'; +import { UserId } from '@shared/models/id/user-id'; + +export const userInfoCommand = (key: string): string => `curl -X GET "${window.location.origin}/api/auth/user" -H "Content-Type: application/json" -H "X-Authorization: ApiKey ${key}"` + +export interface ApiKeyInfo extends BaseData, HasTenantId { + enabled: boolean; + expirationTime: number; + description: string; + expired: boolean; + userId: UserId; +} + +export interface ApiKey extends ApiKeyInfo { + value: string; +} diff --git a/ui-ngx/src/app/shared/models/constants.ts b/ui-ngx/src/app/shared/models/constants.ts index f935828989..68391a42f3 100644 --- a/ui-ngx/src/app/shared/models/constants.ts +++ b/ui-ngx/src/app/shared/models/constants.ts @@ -215,6 +215,7 @@ export const HelpLinks = { mobileQrCode: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/ui/mobile-qr-code/`, calculatedField: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/calculated-fields/`, aiModels: `${helpBaseUrl}/docs${docPlatformPrefix}/samples/analytics/ai-models/`, + apiKeys: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/ui/api-keys`, timewindowSettings: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/dashboards/#time-window`, trendzSettings: `${helpBaseUrl}/docs/trendz/` } diff --git a/ui-ngx/src/app/shared/models/entity-type.models.ts b/ui-ngx/src/app/shared/models/entity-type.models.ts index 6e7ba24578..48428932e4 100644 --- a/ui-ngx/src/app/shared/models/entity-type.models.ts +++ b/ui-ngx/src/app/shared/models/entity-type.models.ts @@ -52,6 +52,7 @@ export enum EntityType { MOBILE_APP = 'MOBILE_APP', CALCULATED_FIELD = 'CALCULATED_FIELD', AI_MODEL = 'AI_MODEL', + API_KEY = 'API_KEY', } export enum AliasEntityType { @@ -506,7 +507,19 @@ export const entityTypeTranslations = new Map Date: Wed, 12 Nov 2025 19:06:42 +0200 Subject: [PATCH 32/56] UI: Add CF output strategy --- .../calculated-field-dialog.component.ts | 10 ++- .../geofencing-configuration.component.ts | 6 +- .../calculated-field-output.component.html | 83 +++++++++++++++++++ .../calculated-field-output.component.scss | 7 ++ .../calculated-field-output.component.ts | 58 ++++++++++++- .../propagation-configuration.component.ts | 5 +- ...ntities-aggregation-component.component.ts | 5 +- .../simple-configuration.component.ts | 7 +- .../shared/models/calculated-field.models.ts | 64 ++++++++++++++ .../assets/locale/locale.constant-en_US.json | 22 +++++ 10 files changed, 249 insertions(+), 18 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.scss diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts index 17c550dcdf..7174ec9dd9 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts @@ -26,7 +26,8 @@ import { CalculatedFieldConfiguration, CalculatedFieldTestScriptFn, CalculatedFieldType, - CalculatedFieldTypeTranslations + CalculatedFieldTypeTranslations, + OutputStrategyType } from '@shared/models/calculated-field.models'; import { oneSpaceInsideRegex } from '@shared/models/regex.constants'; import { EntityType } from '@shared/models/entity-type.models'; @@ -36,7 +37,7 @@ import { CalculatedFieldsService } from '@core/http/calculated-fields.service'; import { Observable } from 'rxjs'; import { EntityId } from '@shared/models/id/entity-id'; import { AdditionalDebugActionConfig } from '@home/components/entity/debug/entity-debug-settings.model'; -import { deepTrim } from '@core/utils'; +import { deepTrim, isDefined } from '@core/utils'; export interface CalculatedFieldDialogData { value?: CalculatedField; @@ -121,6 +122,11 @@ export class CalculatedFieldDialogComponent extends DialogComponent this.fieldFormGroup.get('type').updateValueAndValidity({onlySelf: true})); } diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.component.ts index 846b063042..3406922b9e 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.component.ts @@ -30,9 +30,9 @@ import { CalculatedFieldGeofencingConfiguration, CalculatedFieldOutput, CalculatedFieldType, + defaultCalculatedFieldOutput, getCalculatedFieldCurrentEntityFilter, - notEmptyObjectValidator, - OutputType + notEmptyObjectValidator } from '@shared/models/calculated-field.models'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; import { getCurrentAuthState } from '@core/auth/auth.selectors'; @@ -83,7 +83,7 @@ export class GeofencingConfigurationComponent implements ControlValueAccessor, V zoneGroups: this.fb.control>({}, notEmptyObjectValidator()), scheduledUpdateEnabled: [true], scheduledUpdateInterval: [this.minAllowedScheduledUpdateIntervalInSecForCF], - output: this.fb.control({type: OutputType.Timeseries}) + output: this.fb.control(defaultCalculatedFieldOutput) }); currentEntityFilter: EntityFilter; diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.html index 4d8515ac7e..144a8384ab 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.html @@ -78,6 +78,89 @@ } }
+
+
+
+ {{ 'calculated-fields.output-strategy.strategy' | translate }} +
+ + @for (outputStrategyType of OutputStrategyTypes; track outputStrategyType) { + {{ OutputStrategyTypeTranslations.get(outputStrategyType) | translate }} + } + +
+ @if (outputForm.get('strategy.type').value === OutputStrategyType.IMMEDIATE) { +
+
+ {{ 'calculated-fields.output-strategy.processing-options' | translate }} +
+ + @if (outputForm.get('type').value === OutputType.Timeseries) { + + {{ 'calculated-fields.output-strategy.save-time-series' | translate }} + + + {{ 'calculated-fields.output-strategy.save-latest-values' | translate }} + + } @else { + + {{ 'calculated-fields.output-strategy.save-database' | translate }} + + } + + {{ 'calculated-fields.output-strategy.send-web-sockets' | translate }} + + + {{ 'calculated-fields.output-strategy.save-calculated-fields' | translate }} + + +
+ @if (outputForm.get('type').value === OutputType.Attribute) { +
+ +
+
calculated-fields.output-strategy.update-attributes-only-on-value-change
+
+
+
+ } @else { + + + help_outline + + + } + } +
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.scss b/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.scss new file mode 100644 index 0000000000..e0a24dc86e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.scss @@ -0,0 +1,7 @@ +:host ::ng-deep { + .mat-mdc-chip-disabled { + .mdc-evolution-chip__action { + cursor: default + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.ts index 2a9215b7cf..a3d575af90 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.ts @@ -28,6 +28,8 @@ import { AttributeScope } from '@shared/models/telemetry/telemetry.models'; import { CalculatedFieldOutput, CalculatedFieldSimpleOutput, + OutputStrategyType, + OutputStrategyTypeTranslations, OutputType, OutputTypeTranslations } from '@shared/models/calculated-field.models'; @@ -52,6 +54,7 @@ import { coerceBoolean } from '@shared/decorators/coercion'; multi: true } ], + styleUrls: ['./calculated-field-output.component.scss'], }) export class CalculatedFieldOutputComponent implements ControlValueAccessor, Validator, OnInit, OnChanges { @@ -72,6 +75,10 @@ export class CalculatedFieldOutputComponent implements ControlValueAccessor, Val readonly OutputTypeTranslations = OutputTypeTranslations; readonly EntityType = EntityType; + readonly OutputStrategyType = OutputStrategyType; + readonly OutputStrategyTypes = Object.values(OutputStrategyType) as OutputStrategyType[]; + readonly OutputStrategyTypeTranslations = OutputStrategyTypeTranslations; + private fb = inject(FormBuilder); private destroyRef = inject(DestroyRef); @@ -80,6 +87,16 @@ export class CalculatedFieldOutputComponent implements ControlValueAccessor, Val scope: [{value: AttributeScope.SERVER_SCOPE, disabled: true}], type: [OutputType.Timeseries], decimalsByDefault: [null as number, [Validators.min(0), Validators.max(15), Validators.pattern(digitsRegex)]], + strategy: this.fb.group({ + type: [OutputStrategyType.IMMEDIATE], + saveTimeSeries: [true], + saveLatest: [true], + saveAttribute: [true], + sendWsUpdate: [true], + processCfs: [true], + updateAttributesOnlyOnValueChange: [true], + ttl: [0] + }) }); private propagateChange: (config: CalculatedFieldOutput | CalculatedFieldSimpleOutput) => void = () => { }; @@ -87,14 +104,23 @@ export class CalculatedFieldOutputComponent implements ControlValueAccessor, Val ngOnInit() { this.outputForm.get('type').valueChanges .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(type => this.toggleScopeByOutputType(type)); + .subscribe(type => { + this.toggleScopeByOutputType(type); + this.updatedStrategy(); + }); + + this.outputForm.get('strategy.type').valueChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.updatedStrategy(); + }); this.updatedFormWithMode(); this.outputForm.valueChanges.pipe( takeUntilDestroyed(this.destroyRef) ).subscribe((value: CalculatedFieldOutput | CalculatedFieldSimpleOutput) => { - this.updatedModel(value) + this.updatedModel(value); }) } @@ -134,6 +160,14 @@ export class CalculatedFieldOutputComponent implements ControlValueAccessor, Val this.outputForm.enable({emitEvent: false}); this.updatedFormWithMode(); this.toggleScopeByOutputType(this.outputForm.get('type').value); + this.updatedStrategy(); + } + } + + toggleChip(controlName: string) { + const control = this.outputForm.get('strategy').get(controlName); + if (control && control.enabled) { + control.setValue(!control.value); } } @@ -164,4 +198,24 @@ export class CalculatedFieldOutputComponent implements ControlValueAccessor, Val this.outputForm.get('decimalsByDefault').disable({emitEvent: false}); } } + + private updatedStrategy(): void { + const strategyType = this.outputForm.get('strategy.type').value; + this.outputForm.get('strategy').disable({emitEvent: false}); + this.outputForm.get('strategy.type').enable({emitEvent: false}); + + if (strategyType === OutputStrategyType.IMMEDIATE) { + const outputType = this.outputForm.get('type').value; + this.outputForm.get('strategy.sendWsUpdate').enable({emitEvent: false}); + this.outputForm.get('strategy.processCfs').enable({emitEvent: false}); + if (outputType === OutputType.Attribute) { + this.outputForm.get('strategy.saveAttribute').enable({emitEvent: false}); + this.outputForm.get('strategy.updateAttributesOnlyOnValueChange').enable({emitEvent: false}); + } else { + this.outputForm.get('strategy.saveTimeSeries').enable({emitEvent: false}); + this.outputForm.get('strategy.saveLatest').enable({emitEvent: false}); + this.outputForm.get('strategy.ttl').enable({emitEvent: false}); + } + } + } } diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component.ts index 4fb8bb561e..0a69c59568 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component.ts @@ -31,6 +31,7 @@ import { CalculatedFieldOutput, CalculatedFieldPropagationConfiguration, CalculatedFieldType, + defaultCalculatedFieldOutput, getCalculatedFieldArgumentsEditorCompleter, getCalculatedFieldArgumentsHighlights, notEmptyObjectValidator, @@ -81,9 +82,7 @@ export class PropagationConfigurationComponent implements ControlValueAccessor, relationType: ['Contains', Validators.required], }), expression: [calculatedFieldDefaultScript], - output: this.fb.control({ - type: OutputType.Timeseries, - }), + output: this.fb.control(defaultCalculatedFieldOutput), }); readonly ScriptLanguage = ScriptLanguage; diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.ts index c53f400916..731330ec76 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.ts @@ -30,6 +30,7 @@ import { CalculatedFieldOutput, CalculatedFieldRelatedAggregationConfiguration, CalculatedFieldType, + defaultCalculatedFieldOutput, getCalculatedFieldArgumentsEditorCompleter, getCalculatedFieldArgumentsHighlights, notEmptyObjectValidator, @@ -87,9 +88,7 @@ export class RelatedEntitiesAggregationComponentComponent implements ControlValu arguments: this.fb.control({}, notEmptyObjectValidator()), metrics: this.fb.control({}, notEmptyObjectValidator()), deduplicationIntervalInSec: [this.minAllowedDeduplicationIntervalInSecForCF], - output: this.fb.control({ - type: OutputType.Timeseries, - }), + output: this.fb.control(defaultCalculatedFieldOutput), useLatestTs: [false] }); diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.ts index 065556081c..0c70361e5e 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.ts @@ -31,6 +31,7 @@ import { CalculatedFieldSimpleConfiguration, CalculatedFieldSimpleOutput, CalculatedFieldType, + defaultSimpleCalculatedFieldOutput, getCalculatedFieldArgumentsEditorCompleter, getCalculatedFieldArgumentsHighlights, OutputType @@ -84,11 +85,7 @@ export class SimpleConfigurationComponent implements ControlValueAccessor, Valid arguments: this.fb.control({}), expressionSIMPLE: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]], expressionSCRIPT: [calculatedFieldDefaultScript], - output: this.fb.control({ - name: '', - type: OutputType.Timeseries, - decimalsByDefault: null - }), + output: this.fb.control(defaultSimpleCalculatedFieldOutput), useLatestTs: [false] }); diff --git a/ui-ngx/src/app/shared/models/calculated-field.models.ts b/ui-ngx/src/app/shared/models/calculated-field.models.ts index 1b43700ebe..e08066066c 100644 --- a/ui-ngx/src/app/shared/models/calculated-field.models.ts +++ b/ui-ngx/src/app/shared/models/calculated-field.models.ts @@ -185,10 +185,12 @@ export type CalculatedFieldOutput = export interface CalculatedFieldOutputAttribute { type: OutputType.Attribute, scope: AttributeScope; + strategy: AttributeOutputStrategy; } export interface CalculatedFieldOutputTimeSeries { type: OutputType.Timeseries; + strategy: TimeSeriesOutputStrategy; } export type CalculatedFieldSimpleOutput = CalculatedFieldOutput & { @@ -196,6 +198,51 @@ export type CalculatedFieldSimpleOutput = CalculatedFieldOutput & { decimalsByDefault?: number; } +export type AttributeOutputStrategy = + | AttributeImmediateOutputStrategy + | AttributeRuleChainOutputStrategy; + +export interface AttributeImmediateOutputStrategy { + type: OutputStrategyType.IMMEDIATE; + updateAttributesOnlyOnValueChange: boolean; + saveAttribute: boolean; + sendWsUpdate: boolean; + processCfs: boolean; +} + +export interface AttributeRuleChainOutputStrategy { + type: OutputStrategyType.RULE_CHAIN; +} + +export type TimeSeriesOutputStrategy = + | TimeSeriesRuleChainOutputStrategy + | TimeSeriesImmediateOutputStrategy; + +export interface TimeSeriesRuleChainOutputStrategy { + type: OutputStrategyType.IMMEDIATE; + ttl: number; + saveTimeSeries: boolean; + saveLatest: boolean; + sendWsUpdate: boolean; + processCfs: boolean; +} + +export interface TimeSeriesImmediateOutputStrategy { + type: OutputStrategyType.RULE_CHAIN; +} + +export enum OutputStrategyType { + IMMEDIATE = 'IMMEDIATE', + RULE_CHAIN = 'RULE_CHAIN' +} + +export const OutputStrategyTypeTranslations = new Map( + [ + [OutputStrategyType.IMMEDIATE, 'calculated-fields.output-strategy.process-right-away'], + [OutputStrategyType.RULE_CHAIN, 'calculated-fields.output-strategy.process-rule-chains'], + ] +) + export enum ArgumentEntityType { Current = 'CURRENT', Device = 'DEVICE', @@ -458,6 +505,23 @@ export type CalculatedFieldArgumentEventValue = CalculatedF export type CalculatedFieldEventArguments = Record>; +export const defaultCalculatedFieldOutput: CalculatedFieldOutputTimeSeries = { + type: OutputType.Timeseries, + strategy: { + type: OutputStrategyType.IMMEDIATE, + ttl: 0, + saveTimeSeries: true, + saveLatest: true, + sendWsUpdate: true, + processCfs: true + } +} + +export const defaultSimpleCalculatedFieldOutput: CalculatedFieldSimpleOutput = { + name: '', + ...defaultCalculatedFieldOutput +} + export const CalculatedFieldCtxLatestTelemetryArgumentAutocomplete = { meta: 'object', type: '{ ts: number; value: any; }', diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 0c501f5c5e..b827ead50b 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -1193,6 +1193,28 @@ "filter": "Filter", "filter-hint": "Enables filtering of entities during aggregation. The filter function must return a boolean value and can use all configured arguments." }, + "output-strategy": { + "strategy": "Strategy", + "process-right-away": "Process right away", + "process-rule-chains": "Process via Rule Chains", + "processing-options": "Processing options", + "save-time-series": "Save to time series", + "save-database": "Save to database", + "save-latest-values": "Save to latest values", + "send-web-sockets": "Send to WebSockets", + "save-calculated-fields": "Send to Calculated fields", + "update-attributes-only-on-value-change": "Save attributes only if the value changes", + "ttl": "TTL", + "ttl-required": "TTL is required.", + "ttl-min": "Only 0 minimum TTL is allowed.", + "hint": { + "strategy": "Strategy", + "processing-options": "Processing options", + "update-attributes-only-on-value-change": "Updates the attributes on every incoming message disregarding if their value has changed. Increases API usage and reduces performance.", + "update-attributes-only-on-value-change-enabled": "Updates the attributes only if their value has changed. If the value is not changed, no update to the attribute timestamp nor attribute change notification will be sent.", + "ttl": "If no value is present, it defaults to the TTL specified in the configuration. If the value is set to 0, the TTL from the tenant profile configuration will be applied." + } + }, "hint": { "arguments-simple-with-rolling": "Simple type calculated field should not contain keys with time series rolling type.", "arguments-propagate-arguments-with-rolling": "'Time series rolling' type is incompatible with 'Arguments only' propagation.", From 6352c4e40b3eb5cec3a23e456205b4d5deeba5e1 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Thu, 13 Nov 2025 12:46:36 +0200 Subject: [PATCH 33/56] added subtype when Alarm "Severity changed" comment --- .../service/telemetry/DefaultAlarmSubscriptionService.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultAlarmSubscriptionService.java b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultAlarmSubscriptionService.java index b68f604460..536b1e8a32 100644 --- a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultAlarmSubscriptionService.java +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultAlarmSubscriptionService.java @@ -252,7 +252,10 @@ public class DefaultAlarmSubscriptionService extends AbstractSubscriptionService .alarmId(alarm.getId()) .type(AlarmCommentType.SYSTEM) .comment(JacksonUtil.newObjectNode().put("text", - String.format("Alarm severity was updated from %s to %s", result.getOldSeverity(), alarm.getSeverity()))); + String.format("Alarm severity was updated from %s to %s", result.getOldSeverity(), alarm.getSeverity())) + .put("subtype", "severityChanged") + .put("oldSeverity", result.getOldSeverity().name()) + .put("newSeverity", alarm.getSeverity().name())); if (request != null && request.getUserId() != null) { alarmComment.userId(request.getUserId()); } From 5bf335294e7130187c229cbdd0c5e93d15246514 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Thu, 13 Nov 2025 12:51:45 +0200 Subject: [PATCH 34/56] added subtype for "Deleted" alarm comment --- .../service/entitiy/alarm/DefaultTbAlarmCommentService.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/alarm/DefaultTbAlarmCommentService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/alarm/DefaultTbAlarmCommentService.java index 73f6e0a861..e73500c25e 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/alarm/DefaultTbAlarmCommentService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/alarm/DefaultTbAlarmCommentService.java @@ -61,7 +61,8 @@ public class DefaultTbAlarmCommentService extends AbstractTbEntityService implem alarmComment.setUserId(null); alarmComment.setComment(JacksonUtil.newObjectNode().put("text", String.format("User %s deleted his comment", - (user.getFirstName() == null || user.getLastName() == null) ? user.getName() : user.getFirstName() + " " + user.getLastName()))); + (user.getFirstName() == null || user.getLastName() == null) ? user.getName() : user.getFirstName() + " " + user.getLastName())) + .put("subtype", "deleted")); AlarmComment savedAlarmComment = checkNotNull(alarmCommentService.saveAlarmComment(alarm.getTenantId(), alarmComment)); logEntityActionService.logEntityAction(alarm.getTenantId(), alarm.getId(), alarm, alarm.getCustomerId(), ActionType.DELETED_COMMENT, user, savedAlarmComment); } else { From ca1f1e56866f4ad2af02d998c390f6f750eaec5a Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Thu, 13 Nov 2025 14:49:04 +0200 Subject: [PATCH 35/56] Fix api key permissions --- .../server/controller/ApiKeyController.java | 5 +++++ .../permission/CustomerUserPermissions.java | 2 +- .../security/permission/SysAdminPermissions.java | 13 ++++++++++++- .../security/permission/TenantAdminPermissions.java | 1 - .../server/common/data/pat/ApiKeyInfo.java | 2 ++ 5 files changed, 20 insertions(+), 3 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java b/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java index d9df135811..4078f7855f 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java +++ b/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java @@ -76,6 +76,7 @@ public class ApiKeyController extends BaseController { SecurityUser securityUser = getCurrentUser(); apiKeyInfo.setTenantId(securityUser.getTenantId()); checkEntity(apiKeyInfo.getId(), apiKeyInfo, Resource.API_KEY); + checkUserId(apiKeyInfo.getUserId(), Operation.WRITE); return checkNotNull(apiKeyService.saveApiKey(securityUser.getTenantId(), apiKeyInfo)); } @@ -101,6 +102,7 @@ public class ApiKeyController extends BaseController { PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); UserId userId = new UserId(toUUID(userIdStr)); accessControlService.checkPermission(securityUser, Resource.API_KEY, Operation.READ); + checkUserId(userId, Operation.READ); return apiKeyService.findApiKeysByUserId(securityUser.getTenantId(), userId, pageLink); } @@ -117,6 +119,7 @@ public class ApiKeyController extends BaseController { @RequestBody Optional description) throws Exception { ApiKeyId apiKeyId = new ApiKeyId(id); ApiKey apiKey = checkApiKeyId(apiKeyId, Operation.WRITE); + checkUserId(apiKey.getUserId(), Operation.WRITE); apiKey.setDescription(description.orElse(null)); return apiKeyService.saveApiKey(apiKey.getTenantId(), apiKey); } @@ -132,6 +135,7 @@ public class ApiKeyController extends BaseController { @PathVariable(value = "enabledValue") Boolean enabledValue) throws ThingsboardException { ApiKeyId apiKeyId = new ApiKeyId(id); ApiKey apiKey = checkApiKeyId(apiKeyId, Operation.WRITE); + checkUserId(apiKey.getUserId(), Operation.WRITE); apiKey.setEnabled(enabledValue); return apiKeyService.saveApiKey(apiKey.getTenantId(), apiKey); } @@ -143,6 +147,7 @@ public class ApiKeyController extends BaseController { public void deleteApiKey(@PathVariable UUID id) throws ThingsboardException { ApiKeyId apiKeyId = new ApiKeyId(id); ApiKey apiKey = checkApiKeyId(apiKeyId, Operation.DELETE); + checkUserId(apiKey.getUserId(), Operation.WRITE); apiKeyService.deleteApiKey(apiKey.getTenantId(), apiKey, false); } diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/CustomerUserPermissions.java b/application/src/main/java/org/thingsboard/server/service/security/permission/CustomerUserPermissions.java index a126bad2c1..8fc1bc3bf0 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/permission/CustomerUserPermissions.java +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/CustomerUserPermissions.java @@ -211,7 +211,7 @@ public class CustomerUserPermissions extends AbstractPermissions { @Override @SuppressWarnings("unchecked") public boolean hasPermission(SecurityUser user, Operation operation, ApiKeyId entityId, ApiKeyInfo entity) { - return user.getTenantId().equals(entity.getTenantId()) && user.getId().equals(entity.getUserId()); + return user.getTenantId().equals(entity.getTenantId()); } }; diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/SysAdminPermissions.java b/application/src/main/java/org/thingsboard/server/service/security/permission/SysAdminPermissions.java index 46fdd9a0bb..64c7ad2808 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/permission/SysAdminPermissions.java +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/SysAdminPermissions.java @@ -18,8 +18,10 @@ package org.thingsboard.server.service.security.permission; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.HasTenantId; import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.id.ApiKeyId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.pat.ApiKeyInfo; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.service.security.model.SecurityUser; @@ -45,7 +47,7 @@ public class SysAdminPermissions extends AbstractPermissions { put(Resource.QUEUE, systemEntityPermissionChecker); put(Resource.NOTIFICATION, systemEntityPermissionChecker); put(Resource.MOBILE_APP_SETTINGS, PermissionChecker.allowAllPermissionChecker); - put(Resource.API_KEY, systemEntityPermissionChecker); + put(Resource.API_KEY, PermissionChecker.allowAllPermissionChecker); } private static final PermissionChecker systemEntityPermissionChecker = new PermissionChecker() { @@ -72,4 +74,13 @@ public class SysAdminPermissions extends AbstractPermissions { }; + private static final PermissionChecker apiKeysPermissionChecker = new PermissionChecker<>() { + + @Override + public boolean hasPermission(SecurityUser user, Operation operation) { + return true; + } + + }; + } diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java b/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java index 6e6ebd5cc0..68e0caa520 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java @@ -23,7 +23,6 @@ import org.thingsboard.server.common.data.id.AiModelId; import org.thingsboard.server.common.data.id.ApiKeyId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.UserId; -import org.thingsboard.server.common.data.pat.ApiKey; import org.thingsboard.server.common.data.pat.ApiKeyInfo; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.service.security.model.SecurityUser; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/pat/ApiKeyInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/pat/ApiKeyInfo.java index aea83e5dca..770f4e64f6 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/pat/ApiKeyInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/pat/ApiKeyInfo.java @@ -17,6 +17,7 @@ package org.thingsboard.server.common.data.pat; import com.fasterxml.jackson.annotation.JsonProperty; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; import lombok.Data; import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.BaseData; @@ -47,6 +48,7 @@ public class ApiKeyInfo extends BaseData implements HasTenantId { private long expirationTime; @NoXss + @NotBlank @Length(fieldName = "description") @Schema(description = "Api Key description.", example = "Api Key description") private String description; From 43fb9ae595a018d2e3b1f4f9dcbad2f21797ca24 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Thu, 13 Nov 2025 15:15:10 +0200 Subject: [PATCH 36/56] Minor changes of apiKeysPermissionChecker --- .../service/security/permission/CustomerUserPermissions.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/CustomerUserPermissions.java b/application/src/main/java/org/thingsboard/server/service/security/permission/CustomerUserPermissions.java index 8fc1bc3bf0..d8478678ad 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/permission/CustomerUserPermissions.java +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/CustomerUserPermissions.java @@ -208,6 +208,11 @@ public class CustomerUserPermissions extends AbstractPermissions { private static final PermissionChecker apiKeysPermissionChecker = new PermissionChecker() { + @Override + public boolean hasPermission(SecurityUser user, Operation operation) { + return true; + } + @Override @SuppressWarnings("unchecked") public boolean hasPermission(SecurityUser user, Operation operation, ApiKeyId entityId, ApiKeyInfo entity) { From f639b869f6dc244085bef4c643af3e87966be859 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Thu, 13 Nov 2025 16:10:07 +0200 Subject: [PATCH 37/56] Fix tests --- .../server/dao/service/ApiKeyServiceTest.java | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/ApiKeyServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/ApiKeyServiceTest.java index 0405a77fda..e7658d4fcf 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/ApiKeyServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/ApiKeyServiceTest.java @@ -77,19 +77,6 @@ public class ApiKeyServiceTest extends AbstractServiceTest { Assert.assertNotNull(savedApiKey.getValue()); } - @Test - public void testSaveApiKeyWithEmptyDescription() { - ApiKeyInfo apiKeyInfo = createApiKeyInfo(null); - ApiKey savedApiKey = apiKeyService.saveApiKey(tenantId, apiKeyInfo); - - Assert.assertNotNull(savedApiKey); - Assert.assertNotNull(savedApiKey.getId()); - Assert.assertEquals(tenantId, savedApiKey.getTenantId()); - Assert.assertNull(savedApiKey.getDescription()); - Assert.assertTrue(savedApiKey.isEnabled()); - Assert.assertNotNull(savedApiKey.getValue()); - } - @Test public void testSaveApiKeyWithTooLongDescription() { ApiKeyInfo apiKeyInfo = createApiKeyInfo(StringUtils.randomAlphabetic(300)); From 5ab9fa72a29c6e8e9b4b672600cb7f63faf36eaf Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Thu, 13 Nov 2025 17:26:48 +0200 Subject: [PATCH 38/56] introduced AlarmCommentSubType enum --- .../alarm/DefaultTbAlarmCommentService.java | 10 ++-- .../entitiy/alarm/DefaultTbAlarmService.java | 50 +++++++++++-------- .../DefaultAlarmSubscriptionService.java | 8 +-- .../AlarmCommentControllerTest.java | 13 +++-- .../data/alarm/AlarmCommentSubType.java | 36 +++++++++++++ 5 files changed, 85 insertions(+), 32 deletions(-) create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCommentSubType.java diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/alarm/DefaultTbAlarmCommentService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/alarm/DefaultTbAlarmCommentService.java index e73500c25e..b4a967109b 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/alarm/DefaultTbAlarmCommentService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/alarm/DefaultTbAlarmCommentService.java @@ -30,6 +30,8 @@ import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.dao.alarm.AlarmCommentService; import org.thingsboard.server.service.entitiy.AbstractTbEntityService; +import static org.thingsboard.server.common.data.alarm.AlarmCommentSubType.COMMENT_DELETED; + @Service @AllArgsConstructor public class DefaultTbAlarmCommentService extends AbstractTbEntityService implements TbAlarmCommentService { @@ -59,10 +61,10 @@ public class DefaultTbAlarmCommentService extends AbstractTbEntityService implem if (alarmComment.getType() == AlarmCommentType.OTHER) { alarmComment.setType(AlarmCommentType.SYSTEM); alarmComment.setUserId(null); - alarmComment.setComment(JacksonUtil.newObjectNode().put("text", - String.format("User %s deleted his comment", - (user.getFirstName() == null || user.getLastName() == null) ? user.getName() : user.getFirstName() + " " + user.getLastName())) - .put("subtype", "deleted")); + alarmComment.setComment(JacksonUtil.newObjectNode() + .put("text", String.format(COMMENT_DELETED.getText(), user.getTitle())) + .put("subtype", COMMENT_DELETED.name()) + .put("userName", user.getTitle())); AlarmComment savedAlarmComment = checkNotNull(alarmCommentService.saveAlarmComment(alarm.getTenantId(), alarmComment)); logEntityActionService.logEntityAction(alarm.getTenantId(), alarm.getId(), alarm, alarm.getCustomerId(), ActionType.DELETED_COMMENT, user, savedAlarmComment); } else { diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/alarm/DefaultTbAlarmService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/alarm/DefaultTbAlarmService.java index 5a1dee3bb5..6d713ef656 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/alarm/DefaultTbAlarmService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/alarm/DefaultTbAlarmService.java @@ -27,6 +27,7 @@ import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.alarm.AlarmApiCallResult; import org.thingsboard.server.common.data.alarm.AlarmAssignee; import org.thingsboard.server.common.data.alarm.AlarmComment; +import org.thingsboard.server.common.data.alarm.AlarmCommentSubType; import org.thingsboard.server.common.data.alarm.AlarmCommentType; import org.thingsboard.server.common.data.alarm.AlarmCreateOrUpdateActiveRequest; import org.thingsboard.server.common.data.alarm.AlarmInfo; @@ -39,9 +40,17 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.service.entitiy.AbstractTbEntityService; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.UUID; +import static org.thingsboard.server.common.data.alarm.AlarmCommentSubType.ACKED_BY_USER; +import static org.thingsboard.server.common.data.alarm.AlarmCommentSubType.ASSIGNED_TO_USER; +import static org.thingsboard.server.common.data.alarm.AlarmCommentSubType.CLEARED_BY_USER; +import static org.thingsboard.server.common.data.alarm.AlarmCommentSubType.UNASSIGNED_BY_USER; +import static org.thingsboard.server.common.data.alarm.AlarmCommentSubType.UNASSIGNED_FROM_DELETED_USER; + @Service @AllArgsConstructor @Slf4j @@ -102,8 +111,7 @@ public class DefaultTbAlarmService extends AbstractTbEntityService implements Tb } AlarmInfo alarmInfo = result.getAlarm(); if (result.isModified()) { - String systemComment = String.format("Alarm was acknowledged by user %s", user.getTitle()); - addSystemAlarmComment(alarmInfo, user, "ACK", systemComment); + addSystemAlarmComment(alarmInfo, user, ACKED_BY_USER,"userName", user.getTitle()); logEntityActionService.logEntityAction(alarm.getTenantId(), alarm.getOriginator(), alarmInfo, alarmInfo.getCustomerId(), ActionType.ALARM_ACK, user); } else { @@ -125,8 +133,7 @@ public class DefaultTbAlarmService extends AbstractTbEntityService implements Tb } AlarmInfo alarmInfo = result.getAlarm(); if (result.isCleared()) { - String systemComment = String.format("Alarm was cleared by user %s", user.getTitle()); - addSystemAlarmComment(alarmInfo, user, "CLEAR", systemComment); + addSystemAlarmComment(alarmInfo, user, CLEARED_BY_USER, "userName", user.getTitle()); logEntityActionService.logEntityAction(alarm.getTenantId(), alarm.getOriginator(), alarmInfo, alarmInfo.getCustomerId(), ActionType.ALARM_CLEAR, user); } else { @@ -144,8 +151,7 @@ public class DefaultTbAlarmService extends AbstractTbEntityService implements Tb AlarmInfo alarmInfo = result.getAlarm(); if (result.isModified()) { AlarmAssignee assignee = alarmInfo.getAssignee(); - String systemComment = String.format("Alarm was assigned by user %s to user %s", user.getTitle(), assignee.getTitle()); - addSystemAlarmComment(alarmInfo, user, "ASSIGN", systemComment, assignee.getId()); + addSystemAlarmComment(alarmInfo, user, ASSIGNED_TO_USER,"userName", user.getTitle(), "assigneeName", assignee.getTitle()); logEntityActionService.logEntityAction(alarm.getTenantId(), alarm.getOriginator(), alarmInfo, alarmInfo.getCustomerId(), ActionType.ALARM_ASSIGNED, user); } else { @@ -162,8 +168,7 @@ public class DefaultTbAlarmService extends AbstractTbEntityService implements Tb } AlarmInfo alarmInfo = result.getAlarm(); if (result.isModified()) { - String systemComment = String.format("Alarm was unassigned by user %s", user.getTitle()); - addSystemAlarmComment(alarmInfo, user, "ASSIGN", systemComment); + addSystemAlarmComment(alarmInfo, user, UNASSIGNED_BY_USER, "userName", user.getTitle()); logEntityActionService.logEntityAction(alarm.getTenantId(), alarm.getOriginator(), alarmInfo, alarmInfo.getCustomerId(), ActionType.ALARM_UNASSIGNED, user); } else { @@ -182,8 +187,7 @@ public class DefaultTbAlarmService extends AbstractTbEntityService implements Tb continue; } if (result.isModified()) { - String comment = String.format("Alarm was unassigned because user %s - was deleted", userTitle); - addSystemAlarmComment(result.getAlarm(), null, "ASSIGN", comment); + addSystemAlarmComment(result.getAlarm(), null, UNASSIGNED_FROM_DELETED_USER, "userName", userTitle); logEntityActionService.logEntityAction(result.getAlarm().getTenantId(), result.getAlarm().getOriginator(), result.getAlarm(), result.getAlarm().getCustomerId(), ActionType.ALARM_UNASSIGNED, null); } } @@ -214,20 +218,24 @@ public class DefaultTbAlarmService extends AbstractTbEntityService implements Tb return ts > 0 ? ts : System.currentTimeMillis(); } - private void addSystemAlarmComment(Alarm alarm, User user, String subType, String commentText) { - addSystemAlarmComment(alarm, user, subType, commentText, null); + private void addSystemAlarmComment(Alarm alarm, User user, AlarmCommentSubType subType, String param, String value) { + Map params = new LinkedHashMap<>(); + params.put(param, value); + addSystemAlarmComment(alarm, user, subType, params); + } + + private void addSystemAlarmComment(Alarm alarm, User user, AlarmCommentSubType subType, String param, String value, String param2, String value2) { + Map params = new LinkedHashMap<>(); + params.put(param, value); + params.put(param2, value2); + addSystemAlarmComment(alarm, user, subType, params); } - private void addSystemAlarmComment(Alarm alarm, User user, String subType, String commentText, UserId assigneeId) { + private void addSystemAlarmComment(Alarm alarm, User user, AlarmCommentSubType subType, Map params) { ObjectNode commentNode = JacksonUtil.newObjectNode(); - commentNode.put("text", commentText) - .put("subtype", subType); - if (user != null) { - commentNode.put("userId", user.getId().getId().toString()); - } - if (assigneeId != null) { - commentNode.put("assigneeId", assigneeId.getId().toString()); - } + commentNode.put("text", String.format(subType.getText(), params.values())) + .put("subtype", subType.name()); + params.forEach(commentNode::put); AlarmComment alarmComment = AlarmComment.builder() .alarmId(alarm.getId()) .type(AlarmCommentType.SYSTEM) diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultAlarmSubscriptionService.java b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultAlarmSubscriptionService.java index 536b1e8a32..1226fabf10 100644 --- a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultAlarmSubscriptionService.java +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultAlarmSubscriptionService.java @@ -61,6 +61,8 @@ import org.thingsboard.server.service.subscription.TbSubscriptionUtils; import java.util.Collection; +import static org.thingsboard.server.common.data.alarm.AlarmCommentSubType.SEVERITY_CHANGED; + /** * Created by ashvayka on 27.03.18. */ @@ -251,9 +253,9 @@ public class DefaultAlarmSubscriptionService extends AbstractSubscriptionService AlarmComment.AlarmCommentBuilder alarmComment = AlarmComment.builder() .alarmId(alarm.getId()) .type(AlarmCommentType.SYSTEM) - .comment(JacksonUtil.newObjectNode().put("text", - String.format("Alarm severity was updated from %s to %s", result.getOldSeverity(), alarm.getSeverity())) - .put("subtype", "severityChanged") + .comment(JacksonUtil.newObjectNode() + .put("text", String.format(SEVERITY_CHANGED.getText(), result.getOldSeverity(), alarm.getSeverity())) + .put("subtype", SEVERITY_CHANGED.name()) .put("oldSeverity", result.getOldSeverity().name()) .put("newSeverity", alarm.getSeverity().name())); if (request != null && request.getUserId() != null) { diff --git a/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java index 0dcc037b98..cfa74fa873 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java @@ -48,6 +48,7 @@ import java.util.List; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.thingsboard.server.common.data.alarm.AlarmCommentSubType.COMMENT_DELETED; @Slf4j @ContextConfiguration(classes = {AlarmCommentControllerTest.Config.class}) @@ -207,8 +208,10 @@ public class AlarmCommentControllerTest extends AbstractControllerTest { AlarmComment expectedAlarmComment = AlarmComment.builder() .alarmId(alarm.getId()) .type(AlarmCommentType.SYSTEM) - .comment(JacksonUtil.newObjectNode().put("text", String.format("User %s deleted his comment", - CUSTOMER_USER_EMAIL))) + .comment(JacksonUtil.newObjectNode() + .put("text", String.format(COMMENT_DELETED.getText(), CUSTOMER_USER_EMAIL)) + .put("subtype", COMMENT_DELETED.name()) + .put("userName", CUSTOMER_USER_EMAIL)) .build(); testLogEntityActionEntityEqClass(alarm, alarm.getId(), tenantId, customerId, customerUserId, CUSTOMER_USER_EMAIL, ActionType.DELETED_COMMENT, 1, expectedAlarmComment); } @@ -226,8 +229,10 @@ public class AlarmCommentControllerTest extends AbstractControllerTest { AlarmComment expectedAlarmComment = AlarmComment.builder() .alarmId(alarm.getId()) .type(AlarmCommentType.SYSTEM) - .comment(JacksonUtil.newObjectNode().put("text", String.format("User %s deleted his comment", - TENANT_ADMIN_EMAIL))) + .comment(JacksonUtil.newObjectNode() + .put("text", String.format(COMMENT_DELETED.getText(), TENANT_ADMIN_EMAIL)) + .put("subtype", COMMENT_DELETED.name()) + .put("userName", TENANT_ADMIN_EMAIL)) .build(); testLogEntityActionEntityEqClass(alarm, alarm.getId(), tenantId, customerId, tenantAdminUserId, TENANT_ADMIN_EMAIL, ActionType.DELETED_COMMENT, 1, expectedAlarmComment); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCommentSubType.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCommentSubType.java new file mode 100644 index 0000000000..e7279c1281 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCommentSubType.java @@ -0,0 +1,36 @@ +/** + * Copyright © 2016-2025 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.alarm; + +import lombok.Getter; + +public enum AlarmCommentSubType { + + ACKED_BY_USER("Alarm was acknowledged by user %s"), + CLEARED_BY_USER("Alarm was cleared by user %s"), + ASSIGNED_TO_USER("Alarm was assigned by user %s to user %s"), + UNASSIGNED_BY_USER("Alarm was unassigned by user %s"), + UNASSIGNED_FROM_DELETED_USER("Alarm was unassigned because user %s - was deleted"), + COMMENT_DELETED("User %s deleted his comment"), + SEVERITY_CHANGED("Alarm severity was changed by user %s from %s to %s"); + + @Getter + private final String text; + + AlarmCommentSubType(String text) { + this.text = text; + } +} From adc01b5207d027b26f6e3eef91fb0273a4adfa28 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Thu, 13 Nov 2025 18:01:25 +0200 Subject: [PATCH 39/56] fixed SEVERITY_CHANGED text --- .../server/common/data/alarm/AlarmCommentSubType.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCommentSubType.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCommentSubType.java index e7279c1281..08bdf71e37 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCommentSubType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCommentSubType.java @@ -25,7 +25,7 @@ public enum AlarmCommentSubType { UNASSIGNED_BY_USER("Alarm was unassigned by user %s"), UNASSIGNED_FROM_DELETED_USER("Alarm was unassigned because user %s - was deleted"), COMMENT_DELETED("User %s deleted his comment"), - SEVERITY_CHANGED("Alarm severity was changed by user %s from %s to %s"); + SEVERITY_CHANGED("Alarm severity was updated from %s to %s"); @Getter private final String text; From 9c564dc0d90378168ea2b9d08aba9445f0ceb85a Mon Sep 17 00:00:00 2001 From: IrynaMatveieva Date: Fri, 14 Nov 2025 09:07:22 +0200 Subject: [PATCH 40/56] fixed test --- .../service/cf/ctx/state/SimpleCalculatedFieldStateTest.java | 3 +-- .../server/common/data/cf/configuration/Output.java | 2 ++ 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java index 79b05a1e41..0568f899d7 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java @@ -33,7 +33,6 @@ import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfig import org.thingsboard.server.common.data.cf.configuration.Output; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; -import org.thingsboard.server.common.data.cf.configuration.TimeSeriesOutput; import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.TenantId; @@ -190,7 +189,7 @@ public class SimpleCalculatedFieldStateTest { "key3", new SingleValueArgumentEntry(System.currentTimeMillis() - 3, new DoubleDataEntry("key3", 23.1), 184L) )); - TimeSeriesOutput output = (TimeSeriesOutput) getCalculatedFieldConfig().getOutput(); + Output output = getCalculatedFieldConfig().getOutput(); output.setDecimalsByDefault(3); ctx.setOutput(output); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Output.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Output.java index 4658d41221..848ad759e8 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Output.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Output.java @@ -48,4 +48,6 @@ public interface Output { Integer getDecimalsByDefault(); + void setDecimalsByDefault(Integer decimalsByDefault); + } From d59de750d0f5b54962436cbf8726a73f60867848 Mon Sep 17 00:00:00 2001 From: IrynaMatveieva Date: Fri, 14 Nov 2025 10:35:08 +0200 Subject: [PATCH 41/56] refactoring --- ...tractCalculatedFieldProcessingService.java | 89 ++++++++++--------- 1 file changed, 48 insertions(+), 41 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java b/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java index 6d16e11a17..39ab78f86a 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java @@ -33,10 +33,12 @@ import org.thingsboard.rule.engine.api.AttributesSaveRequest; import org.thingsboard.rule.engine.api.AttributesSaveRequest.Strategy; import org.thingsboard.rule.engine.api.TimeseriesSaveRequest; import org.thingsboard.server.common.adaptor.JsonConverter; +import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentType; import org.thingsboard.server.common.data.cf.configuration.AttributeImmediateOutputStrategy; +import org.thingsboard.server.common.data.cf.configuration.OutputStrategy; import org.thingsboard.server.common.data.cf.configuration.OutputType; import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration; import org.thingsboard.server.common.data.cf.configuration.TimeSeriesImmediateOutputStrategy; @@ -357,8 +359,8 @@ public abstract class AbstractCalculatedFieldProcessingService { SettableFuture future = SettableFuture.create(); switch (type) { - case ATTRIBUTES -> saveAttributes(tenantId, entityId, cfResult, cfIds, future); - case TIME_SERIES -> saveTimeSeries(tenantId, entityId, cfResult, cfIds, System.currentTimeMillis(), future); + case ATTRIBUTES -> saveAttributes(tenantId, entityId, jsonResult, cfResult.getOutputStrategy(), cfResult.getScope(), cfIds, future); + case TIME_SERIES -> saveTimeSeries(tenantId, entityId, jsonResult, cfResult.getOutputStrategy(), cfIds, System.currentTimeMillis(), future); } Futures.addCallback(future, new FutureCallback<>() { @@ -376,39 +378,37 @@ public abstract class AbstractCalculatedFieldProcessingService { }, MoreExecutors.directExecutor()); } - private void saveAttributes(TenantId tenantId, EntityId entityId, TelemetryCalculatedFieldResult cfResult, List cfIds, SettableFuture future) { - if (!(cfResult.getOutputStrategy() instanceof AttributeImmediateOutputStrategy outputStrategy)) { - future.setException(new IllegalArgumentException("Expected AttributeImmediateOutputStrategy")); - return; - } - JsonElement jsonResult = JsonParser.parseString(Objects.requireNonNull(cfResult.stringValue())); + private void saveAttributes(TenantId tenantId, EntityId entityId, JsonElement jsonResult, OutputStrategy outputStrategy, AttributeScope scope, List cfIds, SettableFuture future) { + if (!(outputStrategy instanceof AttributeImmediateOutputStrategy attOutputStrategy)) { + future.setException(new IllegalArgumentException("Only AttributeImmediateOutputStrategy is supported.")); + } else { + AttributesSaveRequest.Strategy strategy = new Strategy(attOutputStrategy.isSaveAttribute(), attOutputStrategy.isSendWsUpdate(), attOutputStrategy.isProcessCfs()); + List newAttributes = JsonConverter.convertToAttributes(jsonResult); - AttributesSaveRequest.Strategy strategy = new Strategy(outputStrategy.isSaveAttribute(), outputStrategy.isSendWsUpdate(), outputStrategy.isProcessCfs()); - List newAttributes = JsonConverter.convertToAttributes(jsonResult); + if (!attOutputStrategy.isUpdateAttributesOnlyOnValueChange()) { + saveAttributesInternal(tenantId, entityId, scope, cfIds, newAttributes, strategy, future); + return; + } - if (!outputStrategy.isUpdateAttributesOnlyOnValueChange()) { - saveAttributesInternal(tenantId, entityId, cfResult, cfIds, newAttributes, strategy, future); - return; - } + List keys = newAttributes.stream().map(KvEntry::getKey).collect(Collectors.toList()); + ListenableFuture> findFuture = attributesService.find(tenantId, entityId, scope, keys); - List keys = newAttributes.stream().map(KvEntry::getKey).collect(Collectors.toList()); - ListenableFuture> findFuture = attributesService.find(tenantId, entityId, cfResult.getScope(), keys); - - DonAsynchron.withCallback(findFuture, - existingAttributes -> { - List changed = filterChangedAttr(existingAttributes, newAttributes); - if (changed.isEmpty()) { - future.set(null); - return; - } - saveAttributesInternal(tenantId, entityId, cfResult, cfIds, changed, strategy, future); - }, - future::setException, - MoreExecutors.directExecutor()); + DonAsynchron.withCallback(findFuture, + existingAttributes -> { + List changed = filterChangedAttr(existingAttributes, newAttributes); + if (changed.isEmpty()) { + future.set(null); + return; + } + saveAttributesInternal(tenantId, entityId, scope, cfIds, changed, strategy, future); + }, + future::setException, + MoreExecutors.directExecutor()); + } } private void saveAttributesInternal(TenantId tenantId, EntityId entityId, - TelemetryCalculatedFieldResult cfResult, + AttributeScope scope, List cfIds, List entries, AttributesSaveRequest.Strategy strategy, @@ -416,7 +416,7 @@ public abstract class AbstractCalculatedFieldProcessingService { tsSubService.saveAttributesInternal(AttributesSaveRequest.builder() .tenantId(tenantId) .entityId(entityId) - .scope(cfResult.getScope()) + .scope(scope) .entries(entries) .strategy(strategy) .previousCalculatedFieldIds(cfIds) @@ -424,28 +424,35 @@ public abstract class AbstractCalculatedFieldProcessingService { .build()); } - private void saveTimeSeries(TenantId tenantId, EntityId entityId, TelemetryCalculatedFieldResult cfResult, List cfIds, long ts, SettableFuture future) { - if (!(cfResult.getOutputStrategy() instanceof TimeSeriesImmediateOutputStrategy outputStrategy)) { - future.setException(new IllegalArgumentException("Expected TimeSeriesImmediateOutputStrategy")); - return; + private void saveTimeSeries(TenantId tenantId, EntityId entityId, JsonElement jsonResult, OutputStrategy outputStrategy, List cfIds, long ts, SettableFuture future) { + if (!(outputStrategy instanceof TimeSeriesImmediateOutputStrategy tsOutputStrategy)) { + future.setException(new IllegalArgumentException("Only TimeSeriesImmediateOutputStrategy is supported.")); + } else { + TimeseriesSaveRequest.Strategy strategy = new TimeseriesSaveRequest.Strategy(tsOutputStrategy.isSaveTimeSeries(), tsOutputStrategy.isSaveLatest(), tsOutputStrategy.isSendWsUpdate(), tsOutputStrategy.isProcessCfs()); + saveTimeSeriesInternal(tenantId, entityId, jsonResult, tsOutputStrategy.getTtl(), cfIds, ts, strategy, future); } - JsonElement jsonResult = JsonParser.parseString(Objects.requireNonNull(cfResult.stringValue())); + } + + private void saveTimeSeriesInternal(TenantId tenantId, EntityId entityId, JsonElement jsonResult, Long ttl, List cfIds, long ts, TimeseriesSaveRequest.Strategy strategy, SettableFuture future) { Map> tsKvMap = JsonConverter.convertToTelemetry(jsonResult, ts); if (tsKvMap.isEmpty()) { future.set(null); return; } List tsEntries = toTsKvEntryList(tsKvMap); - TimeseriesSaveRequest.Strategy strategy = new TimeseriesSaveRequest.Strategy(outputStrategy.isSaveTimeSeries(), outputStrategy.isSaveLatest(), outputStrategy.isSendWsUpdate(), outputStrategy.isProcessCfs()); - tsSubService.saveTimeseriesInternal(TimeseriesSaveRequest.builder() + TimeseriesSaveRequest.Builder builder = TimeseriesSaveRequest.builder() .tenantId(tenantId) .entityId(entityId) .entries(tsEntries) - .ttl(outputStrategy.getTtl()) .strategy(strategy) - .previousCalculatedFieldIds(cfIds) - .future(future) - .build()); + .future(future); + if (ttl != null) { + builder.ttl(ttl); + } + if (cfIds != null && !cfIds.isEmpty()) { + builder.previousCalculatedFieldIds(cfIds); + } + tsSubService.saveTimeseriesInternal(builder.build()); } } From 7f56611ae973d24d3e4f9b214831e3af5f92f2f6 Mon Sep 17 00:00:00 2001 From: deaflynx Date: Fri, 14 Nov 2025 13:24:35 +0200 Subject: [PATCH 42/56] Api keys fixes after review. --- .../dialog/add-api-key-dialog.component.html | 8 ++- .../dialog/add-api-key-dialog.component.ts | 34 ++++----- .../api-key-generated-dialog.component.html | 71 ++++++++++++++++--- .../api-key-generated-dialog.component.scss | 12 +++- .../api-key-generated-dialog.component.ts | 22 ++++++ .../api-keys-table-dialog.component.html | 38 +++++----- .../api-keys-table-dialog.component.scss | 25 +++---- .../home/components/home-components.module.ts | 3 - .../home/pages/user/user-tabs.component.html | 2 +- ui-ngx/src/app/shared/models/id/public-api.ts | 1 + .../assets/locale/locale.constant-en_US.json | 6 +- 11 files changed, 146 insertions(+), 76 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/api-key/components/dialog/add-api-key-dialog.component.html b/ui-ngx/src/app/modules/home/components/api-key/components/dialog/add-api-key-dialog.component.html index f1db112383..e91ede1277 100644 --- a/ui-ngx/src/app/modules/home/components/api-key/components/dialog/add-api-key-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/api-key/components/dialog/add-api-key-dialog.component.html @@ -46,9 +46,11 @@
- - {{ value | dateExpiration }} - + @for (value of expirationTimeOptions; track value) { + + {{ value | dateExpiration }} + + } {{'api-key.expiration-time-never' | translate}} {{'api-key.expiration-time-custom' | translate}} diff --git a/ui-ngx/src/app/modules/home/components/api-key/components/dialog/add-api-key-dialog.component.ts b/ui-ngx/src/app/modules/home/components/api-key/components/dialog/add-api-key-dialog.component.ts index b5611bca5a..548f2661fe 100644 --- a/ui-ngx/src/app/modules/home/components/api-key/components/dialog/add-api-key-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/api-key/components/dialog/add-api-key-dialog.component.ts @@ -15,13 +15,13 @@ /// -import { Component, OnInit, Inject } from '@angular/core'; +import { Component, Inject } from '@angular/core'; import { DialogComponent } from '@shared/components/dialog.component'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { Router } from '@angular/router'; import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; -import { FormBuilder, Validators, UntypedFormGroup } from '@angular/forms'; +import { FormBuilder, Validators } from '@angular/forms'; import { deepTrim } from '@core/utils'; import { ApiKeyService } from '@core/http/api-key.service'; import { ApiKeyInfo } from '@shared/models/api-key.models'; @@ -33,14 +33,16 @@ import { DAY } from '@shared/models/time/time.models'; templateUrl: './add-api-key-dialog.component.html', styleUrls: ['./add-api-key-dialog.component.scss'] }) -export class AddApiKeyDialogComponent extends DialogComponent implements OnInit{ - - apiKeyForm: UntypedFormGroup; +export class AddApiKeyDialogComponent extends DialogComponent { readonly startDate = new Date(); - readonly expirationDates = [7, 30, 60, 90].map(days => days * DAY); - - private defaultExpirationDate = this.expirationDates[1]; + readonly expirationTimeOptions: Array = [7, 30, 60, 90].map(days => days * DAY); + readonly apiKeyForm = this.fb.group({ + description: [{value: null, disabled: false}, [Validators.required]], + enabled: [{value: true, disabled: false}, []], + expirationTime: [{value: this.expirationTimeOptions[1] as string | number, disabled: false}, [Validators.required]], + customExpirationTime: [{value: null, disabled: true}, []], + }); constructor( protected store: Store, @@ -53,15 +55,6 @@ export class AddApiKeyDialogComponent extends DialogComponent -

{{ 'api-key.generated-title' | translate }}

+

{{ 'api-key.generated-api-key-title' | translate }}

+ + +
+
device.connectivity.execute-following-command
+ +
+
diff --git a/ui-ngx/src/app/modules/home/components/api-key/components/dialog/api-key-generated-dialog.component.scss b/ui-ngx/src/app/modules/home/components/api-key/components/dialog/api-key-generated-dialog.component.scss index 3ea6dcb614..ecd4de8363 100644 --- a/ui-ngx/src/app/modules/home/components/api-key/components/dialog/api-key-generated-dialog.component.scss +++ b/ui-ngx/src/app/modules/home/components/api-key/components/dialog/api-key-generated-dialog.component.scss @@ -14,10 +14,16 @@ * limitations under the License. */ :host{ - display: block; - width: 600px; + display: grid; + width: 500px; + height: 100%; max-width: 100%; - color: rgba(0, 0, 0, 0.76); + max-height: 100vh; + grid-template-rows: min-content minmax(auto, 1fr) min-content; + + .tb-install-instruction-text { + min-height: 42px; + } } :host ::ng-deep { diff --git a/ui-ngx/src/app/modules/home/components/api-key/components/dialog/api-key-generated-dialog.component.ts b/ui-ngx/src/app/modules/home/components/api-key/components/dialog/api-key-generated-dialog.component.ts index 034b1fcca5..b60ba9ecde 100644 --- a/ui-ngx/src/app/modules/home/components/api-key/components/dialog/api-key-generated-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/api-key/components/dialog/api-key-generated-dialog.component.ts @@ -21,6 +21,7 @@ import { AppState } from '@core/core.state'; import { Router } from '@angular/router'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { userInfoCommand, ApiKey } from '@shared/models/api-key.models'; +import { getOS } from '@core/utils'; export interface ApiKeyGeneratedDialogData { apiKey: ApiKey; @@ -34,12 +35,14 @@ export interface ApiKeyGeneratedDialogData { export class ApiKeyGeneratedDialogComponent extends DialogComponent { apiKeyCommand = userInfoCommand(this.data.apiKey.value); + selectedTab: number; constructor(protected store: Store, protected router: Router, protected dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) public data: ApiKeyGeneratedDialogData) { super(store, router, dialogRef); + this.selectTabIndexForUserOS(); } close(): void { @@ -52,4 +55,23 @@ export class ApiKeyGeneratedDialogComponent extends DialogComponent - -

{{ 'api-key.manage-api-keys' | translate }}

- -
- -
- -
- +
+ +

{{ 'api-key.manage-api-keys' | translate }}

+ +
+ +
+
+ +
+
+ +
diff --git a/ui-ngx/src/app/modules/home/components/api-key/components/dialog/api-keys-table-dialog.component.scss b/ui-ngx/src/app/modules/home/components/api-key/components/dialog/api-keys-table-dialog.component.scss index 2de6ec9c16..1f10a6166e 100644 --- a/ui-ngx/src/app/modules/home/components/api-key/components/dialog/api-keys-table-dialog.component.scss +++ b/ui-ngx/src/app/modules/home/components/api-key/components/dialog/api-keys-table-dialog.component.scss @@ -14,23 +14,14 @@ * limitations under the License. */ -:host ::ng-deep { - tb-api-keys-table { - tb-entities-table { - .tb-absolute-fill { - position: relative; - } - .table-container { - width: 1000px; - min-height: 625px; - max-height: 625px; - } - .no-data-found { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - } +:host { + .api-keys-dialog-container { + width: 1080px; + max-width: 100%; + + .api-keys-dialog-content { + height: 65vh; + border-radius: 0; } } } diff --git a/ui-ngx/src/app/modules/home/components/home-components.module.ts b/ui-ngx/src/app/modules/home/components/home-components.module.ts index 365b5fa82a..cfafe70a96 100644 --- a/ui-ngx/src/app/modules/home/components/home-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/home-components.module.ts @@ -512,9 +512,6 @@ import { ResourcesLibraryComponent, ApiKeysTableComponent, ApiKeysTableDialogComponent, - AddApiKeyDialogComponent, - EditApiKeyDescriptionPanelComponent, - ApiKeyGeneratedDialogComponent, ], providers: [ WidgetComponentService, diff --git a/ui-ngx/src/app/modules/home/pages/user/user-tabs.component.html b/ui-ngx/src/app/modules/home/pages/user/user-tabs.component.html index 79a7a51b79..14be03c2f3 100644 --- a/ui-ngx/src/app/modules/home/pages/user/user-tabs.component.html +++ b/ui-ngx/src/app/modules/home/pages/user/user-tabs.component.html @@ -40,7 +40,7 @@ label="{{ 'audit-log.audit-logs' | translate }}" #auditLogsTab="matTab"> - diff --git a/ui-ngx/src/app/shared/models/id/public-api.ts b/ui-ngx/src/app/shared/models/id/public-api.ts index 14db7a4b14..86787da6b8 100644 --- a/ui-ngx/src/app/shared/models/id/public-api.ts +++ b/ui-ngx/src/app/shared/models/id/public-api.ts @@ -45,3 +45,4 @@ export * from './widgets-bundle-id'; export * from './edge-id'; export * from './asset-id'; export * from './ai-model-id'; +export * from './api-key-id'; diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index efe99ac061..7963f9d23d 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -998,9 +998,9 @@ "generate": "Generate", "generate-title": "Generate API key", "generate-text": "Note: The API key's permissions will inherit the rights of the user account that generates it.", - "generated-title": "API key successfully generated", - "generated-text": "Make sure to copy and save your API key now as you will not be able to see it again.", - "generated-command-title": "Execute following command to display information about current user:", + "generated-api-key-title": "API key generated. Let’s check connectivity!", + "generated-api-key-copy": "Make sure to copy and save your API key now as you will not be able to see it again.", + "generated-api-key-command": "Use the following instructions to check connectivity. As a result, you should receive the current user information:", "list": "{ count, plural, =1 {One API key} other {List of # API keys} }", "manage": "Manage", "manage-api-keys": "Manage API keys", From 61f06a0b9b44583c8e6374195afb28fe5b060547 Mon Sep 17 00:00:00 2001 From: IrynaMatveieva Date: Fri, 14 Nov 2025 16:33:33 +0200 Subject: [PATCH 43/56] fixed tests --- .../RelatedEntitiesAggregationCalculatedFieldState.java | 1 + 1 file changed, 1 insertion(+) diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/RelatedEntitiesAggregationCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/RelatedEntitiesAggregationCalculatedFieldState.java index ff50cd99b6..0aaebdfc80 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/RelatedEntitiesAggregationCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/RelatedEntitiesAggregationCalculatedFieldState.java @@ -166,6 +166,7 @@ public class RelatedEntitiesAggregationCalculatedFieldState extends BaseCalculat lastMetricsEvalTs = System.currentTimeMillis(); scheduleReevaluation(); return Futures.immediateFuture(TelemetryCalculatedFieldResult.builder() + .outputStrategy(output.getStrategy()) .type(output.getType()) .scope(output.getScope()) .result(toSimpleResult(ctx.isUseLatestTs(), aggResult)) From 322f6ecff3a99902bba51c22fbed494fdfeee416 Mon Sep 17 00:00:00 2001 From: Maksym Tsymbarov Date: Fri, 14 Nov 2025 18:18:05 +0200 Subject: [PATCH 44/56] UI: Added translations for system alarms comments --- .../entitiy/alarm/DefaultTbAlarmService.java | 2 +- .../alarm/alarm-comment.component.ts | 19 +++++++++++++++++-- ui-ngx/src/app/shared/models/alarm.models.ts | 13 +++++++++++++ .../assets/locale/locale.constant-en_US.json | 11 ++++++++++- 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/alarm/DefaultTbAlarmService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/alarm/DefaultTbAlarmService.java index 6d713ef656..ac363486fd 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/alarm/DefaultTbAlarmService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/alarm/DefaultTbAlarmService.java @@ -233,7 +233,7 @@ public class DefaultTbAlarmService extends AbstractTbEntityService implements Tb private void addSystemAlarmComment(Alarm alarm, User user, AlarmCommentSubType subType, Map params) { ObjectNode commentNode = JacksonUtil.newObjectNode(); - commentNode.put("text", String.format(subType.getText(), params.values())) + commentNode.put("text", String.format(subType.getText(), params.values().toArray())) .put("subtype", subType.name()); params.forEach(commentNode::put); AlarmComment alarmComment = AlarmComment.builder() diff --git a/ui-ngx/src/app/modules/home/components/alarm/alarm-comment.component.ts b/ui-ngx/src/app/modules/home/components/alarm/alarm-comment.component.ts index cc99ccf411..800fdf2371 100644 --- a/ui-ngx/src/app/modules/home/components/alarm/alarm-comment.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm/alarm-comment.component.ts @@ -27,7 +27,13 @@ import { Direction, SortOrder } from '@shared/models/page/sort-order'; import { MAX_SAFE_PAGE_SIZE, PageLink } from '@shared/models/page/page-link'; import { DateAgoPipe } from '@shared/pipe/date-ago.pipe'; import { map } from 'rxjs/operators'; -import { AlarmComment, AlarmCommentType, getUserDisplayName } from '@shared/models/alarm.models'; +import { + AlarmComment, + AlarmCommentInfo, + AlarmCommentType, + AlarmMessage, + getUserDisplayName +} from '@shared/models/alarm.models'; import { UtilsService } from '@core/services/utils.service'; import { EntityType } from '@shared/models/entity-type.models'; import { DatePipe } from '@angular/common'; @@ -121,7 +127,7 @@ export class AlarmCommentComponent implements OnInit { const displayDataElement = {} as AlarmCommentsDisplayData; displayDataElement.createdTime = this.datePipe.transform(alarmComment.createdTime, 'yyyy-MM-dd HH:mm:ss'); displayDataElement.createdDateAgo = this.dateAgoPipe.transform(alarmComment.createdTime); - displayDataElement.commentText = alarmComment.comment.text; + displayDataElement.commentText = this.parseSystemComment(alarmComment); displayDataElement.isSystemComment = alarmComment.type === AlarmCommentType.SYSTEM; if (alarmComment.type === AlarmCommentType.OTHER) { displayDataElement.commentId = alarmComment.id.id; @@ -144,6 +150,15 @@ export class AlarmCommentComponent implements OnInit { ); } + private parseSystemComment(alarm: AlarmCommentInfo): string { + const subTypeKey = alarm.comment?.subtype; + if (subTypeKey && AlarmMessage[subTypeKey]) { + const translationKey = AlarmMessage[subTypeKey]; + return this.translate.instant(translationKey, alarm.comment); + } + return alarm.comment.text; + } + changeSortDirection() { const currentDirection = this.alarmCommentSortOrder.direction; this.alarmCommentSortOrder.direction = currentDirection === Direction.DESC ? Direction.ASC : Direction.DESC; diff --git a/ui-ngx/src/app/shared/models/alarm.models.ts b/ui-ngx/src/app/shared/models/alarm.models.ts index 590e866465..d512e32bb1 100644 --- a/ui-ngx/src/app/shared/models/alarm.models.ts +++ b/ui-ngx/src/app/shared/models/alarm.models.ts @@ -120,12 +120,25 @@ export enum AlarmCommentType { OTHER = 'OTHER' } +export enum AlarmMessage { + ACKED_BY_USER = "alarm.system-comments.acked-by-user", + CLEARED_BY_USER = "alarm.system-comments.cleared-by-user", + ASSIGNED_TO_USER = "alarm.system-comments.assigned-to-user", + UNASSIGNED_BY_USER = "alarm.system-comments.unassigned-to-user", + UNASSIGNED_FROM_DELETED_USER = "alarm.system-comments.unassigned-from-deleted-user", + COMMENT_DELETED = "alarm.system-comments.comment-deleted", + SEVERITY_CHANGED = "alarm.system-comments.severity-changed", +} + export interface AlarmComment extends BaseData { alarmId: AlarmId; userId?: UserId; type: AlarmCommentType; comment: { text: string; + subtype?: keyof typeof AlarmMessage; + userName?: string; + assigneeName?: string; edited?: boolean; editedOn?: number; }; diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 0c501f5c5e..482dfb0051 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -657,7 +657,16 @@ "alarm-type": "Alarm type", "enter-alarm-type": "Enter alarm type", "no-alarm-types-matching": "No alarm types matching '{{entitySubtype}}' were found.", - "alarm-type-list-empty": "No alarm types selected." + "alarm-type-list-empty": "No alarm types selected.", + "system-comments": { + "acked-by-user": "Alarm was acknowledged by user {{userName}}", + "cleared-by-user": "Alarm was cleared by user {{userName}}", + "assigned-to-user": "Alarm was assigned by user {{userName}} to user {{assigneeName}}", + "unassigned-to-user": "Alarm was unassigned by user {{userName}}", + "unassigned-from-deleted-user": "Alarm was unassigned because user {{userName}} was deleted", + "comment-deleted": "User {{userName}} deleted his comment", + "severity-changed": "Alarm severity changed from {{userName}} to {{assigneeName}}" + } }, "alarm-activity": { "add": "Add a comment...", From 4e4dfdfe6577f37e1fa583f5379700ba665d96fd Mon Sep 17 00:00:00 2001 From: IrynaMatveieva Date: Mon, 17 Nov 2025 08:56:04 +0200 Subject: [PATCH 45/56] fixed compilation error in tests --- .../server/msa/cf/CalculatedFieldTest.java | 30 ++++++++----------- 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/cf/CalculatedFieldTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/cf/CalculatedFieldTest.java index 9c043eee8d..481a85e3f7 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/cf/CalculatedFieldTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/cf/CalculatedFieldTest.java @@ -31,13 +31,13 @@ import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentType; -import org.thingsboard.server.common.data.cf.configuration.Output; -import org.thingsboard.server.common.data.cf.configuration.OutputType; +import org.thingsboard.server.common.data.cf.configuration.AttributesOutput; import org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration; import org.thingsboard.server.common.data.cf.configuration.ScriptCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.TimeSeriesOutput; import org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates; import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.geofencing.ZoneGroupConfiguration; @@ -183,10 +183,11 @@ public class CalculatedFieldTest extends AbstractContainerTest { CalculatedField savedCalculatedField = createSimpleCalculatedField(); - Output savedOutput = savedCalculatedField.getConfiguration().getOutput(); - savedOutput.setType(OutputType.ATTRIBUTES); - savedOutput.setScope(SERVER_SCOPE); - savedOutput.setName("temperatureF"); + AttributesOutput output = new AttributesOutput(); + output.setScope(SERVER_SCOPE); + output.setName("temperatureF"); + ((SimpleCalculatedFieldConfiguration) savedCalculatedField.getConfiguration()).setOutput(output); + testRestClient.postCalculatedField(savedCalculatedField); await().alias("update CF output -> perform calculation with updated output").atMost(TIMEOUT, TimeUnit.SECONDS) @@ -381,8 +382,7 @@ public class CalculatedFieldTest extends AbstractContainerTest { cfg.setZoneGroups(Map.of("allowedZones", allowedZoneGroupConfiguration, "restrictedZones", restrictedZoneGroupConfiguration)); - Output out = new Output(); - out.setType(OutputType.ATTRIBUTES); + AttributesOutput out = new AttributesOutput(); out.setScope(SERVER_SCOPE); cfg.setOutput(out); cf.setConfiguration(cfg); @@ -458,8 +458,7 @@ public class CalculatedFieldTest extends AbstractContainerTest { cfg.setExpression("{\"testResult\": t * 2}"); - Output output = new Output(); - output.setType(OutputType.ATTRIBUTES); + AttributesOutput output = new AttributesOutput(); output.setScope(AttributeScope.SERVER_SCOPE); cfg.setOutput(output); @@ -541,9 +540,7 @@ public class CalculatedFieldTest extends AbstractContainerTest { arg.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); cfg.setArguments(Map.of("temperatureComputed", arg)); - Output output = new Output(); - output.setType(OutputType.TIME_SERIES); - cfg.setOutput(output); + cfg.setOutput(new TimeSeriesOutput()); cf.setConfiguration(cfg); @@ -613,9 +610,8 @@ public class CalculatedFieldTest extends AbstractContainerTest { config.setExpression("(T * 9/5) + 32"); - Output output = new Output(); + TimeSeriesOutput output = new TimeSeriesOutput(); output.setName("fahrenheitTemp"); - output.setType(OutputType.TIME_SERIES); output.setDecimalsByDefault(2); config.setOutput(output); @@ -647,9 +643,7 @@ public class CalculatedFieldTest extends AbstractContainerTest { config.setExpression(exampleScript); - Output output = new Output(); - output.setType(OutputType.TIME_SERIES); - config.setOutput(output); + config.setOutput(new TimeSeriesOutput()); calculatedField.setConfiguration(config); From 0a0f350b6042ceb783fe3e371e9ec1513fbf6121 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Mon, 17 Nov 2025 15:38:49 +0200 Subject: [PATCH 46/56] refactoring --- .../server/service/entitiy/alarm/DefaultTbAlarmService.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/alarm/DefaultTbAlarmService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/alarm/DefaultTbAlarmService.java index ac363486fd..8dafe4725f 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/alarm/DefaultTbAlarmService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/alarm/DefaultTbAlarmService.java @@ -151,7 +151,7 @@ public class DefaultTbAlarmService extends AbstractTbEntityService implements Tb AlarmInfo alarmInfo = result.getAlarm(); if (result.isModified()) { AlarmAssignee assignee = alarmInfo.getAssignee(); - addSystemAlarmComment(alarmInfo, user, ASSIGNED_TO_USER,"userName", user.getTitle(), "assigneeName", assignee.getTitle()); + addSystemAlarmComment(alarmInfo, user, ASSIGNED_TO_USER, "userName", user.getTitle(), "assigneeName", assignee.getTitle()); logEntityActionService.logEntityAction(alarm.getTenantId(), alarm.getOriginator(), alarmInfo, alarmInfo.getCustomerId(), ActionType.ALARM_ASSIGNED, user); } else { @@ -219,13 +219,13 @@ public class DefaultTbAlarmService extends AbstractTbEntityService implements Tb } private void addSystemAlarmComment(Alarm alarm, User user, AlarmCommentSubType subType, String param, String value) { - Map params = new LinkedHashMap<>(); + Map params = new LinkedHashMap<>(1); params.put(param, value); addSystemAlarmComment(alarm, user, subType, params); } private void addSystemAlarmComment(Alarm alarm, User user, AlarmCommentSubType subType, String param, String value, String param2, String value2) { - Map params = new LinkedHashMap<>(); + Map params = new LinkedHashMap<>(2); params.put(param, value); params.put(param2, value2); addSystemAlarmComment(alarm, user, subType, params); From eadcd5413cf541ee676c72bd8fcd9e4572ecddf8 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Mon, 17 Nov 2025 16:04:23 +0200 Subject: [PATCH 47/56] fixed locale.constant-en_US.json --- ui-ngx/src/assets/locale/locale.constant-en_US.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 482dfb0051..35cec38fe2 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -663,9 +663,9 @@ "cleared-by-user": "Alarm was cleared by user {{userName}}", "assigned-to-user": "Alarm was assigned by user {{userName}} to user {{assigneeName}}", "unassigned-to-user": "Alarm was unassigned by user {{userName}}", - "unassigned-from-deleted-user": "Alarm was unassigned because user {{userName}} was deleted", + "unassigned-from-deleted-user": "Alarm was unassigned because user {{userName}} - was deleted", "comment-deleted": "User {{userName}} deleted his comment", - "severity-changed": "Alarm severity changed from {{userName}} to {{assigneeName}}" + "severity-changed": "Alarm severity was updated from {{oldSeverity}} to {{newSeverity}}" } }, "alarm-activity": { From 2b318d53d97bee3742ccb8dc9a3e82b299804f3f Mon Sep 17 00:00:00 2001 From: Vladyslav Prykhodko Date: Mon, 17 Nov 2025 16:15:27 +0200 Subject: [PATCH 48/56] Update alarm.models.ts --- ui-ngx/src/app/shared/models/alarm.models.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ui-ngx/src/app/shared/models/alarm.models.ts b/ui-ngx/src/app/shared/models/alarm.models.ts index d512e32bb1..5e1e5def25 100644 --- a/ui-ngx/src/app/shared/models/alarm.models.ts +++ b/ui-ngx/src/app/shared/models/alarm.models.ts @@ -139,6 +139,8 @@ export interface AlarmComment extends BaseData { subtype?: keyof typeof AlarmMessage; userName?: string; assigneeName?: string; + oldSeverity?: AlarmSeverity; + newSeverity?: AlarmSeverity; edited?: boolean; editedOn?: number; }; From 05f48f3a03aaf5e00737bfe6a1030bb26764914a Mon Sep 17 00:00:00 2001 From: deaflynx Date: Mon, 17 Nov 2025 17:04:25 +0200 Subject: [PATCH 49/56] Refactor 'Add API key' dialog to display as grid. --- .../dialog/add-api-key-dialog.component.html | 127 +++++++++--------- .../dialog/add-api-key-dialog.component.scss | 16 ++- 2 files changed, 75 insertions(+), 68 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/api-key/components/dialog/add-api-key-dialog.component.html b/ui-ngx/src/app/modules/home/components/api-key/components/dialog/add-api-key-dialog.component.html index e91ede1277..ce53d58e0f 100644 --- a/ui-ngx/src/app/modules/home/components/api-key/components/dialog/add-api-key-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/api-key/components/dialog/add-api-key-dialog.component.html @@ -15,72 +15,69 @@ limitations under the License. --> - - -

{{ 'api-key.generate-title' | translate }}

- -
- -
- @if (isLoading$ | async) { - - } @else { -
- } -
-
-
- api-key.generate-text -
- - api-key.description - + +

{{ 'api-key.generate-title' | translate }}

+ +
+ +
+@if (isLoading$ | async) { + +} +
+
+
+ api-key.generate-text +
+ + api-key.description + + + + {{ 'api-key.enable' | translate }} + +
+ + + @for (value of expirationTimeOptions; track value) { + + {{ value | dateExpiration }} + + } + {{'api-key.expiration-time-never' | translate}} + {{'api-key.expiration-time-custom' | translate}} + - - {{ 'api-key.enable' | translate }} - -
- - - @for (value of expirationTimeOptions; track value) { - - {{ value | dateExpiration }} - - } - {{'api-key.expiration-time-never' | translate}} - {{'api-key.expiration-time-custom' | translate}} - + @if (isCustomExpirationTime()) { + + api-key.date + + + - @if (isCustomExpirationTime()) { - - api-key.date - - - - - } -
+ }
-
-
- - -
- +
+
+
+ + +
diff --git a/ui-ngx/src/app/modules/home/components/api-key/components/dialog/add-api-key-dialog.component.scss b/ui-ngx/src/app/modules/home/components/api-key/components/dialog/add-api-key-dialog.component.scss index cbed02a70e..c61a69e729 100644 --- a/ui-ngx/src/app/modules/home/components/api-key/components/dialog/add-api-key-dialog.component.scss +++ b/ui-ngx/src/app/modules/home/components/api-key/components/dialog/add-api-key-dialog.component.scss @@ -15,9 +15,19 @@ */ @import '../../src/scss/constants'; -:host{ - form { - width: 700px; +:host { + display: grid; + width: 700px; + height: 100%; + max-width: 100%; + max-height: 100vh; + grid-template-rows: min-content 4px minmax(auto, 1fr) min-content; + + .mat-mdc-dialog-content { + grid-row: 3; + } + .mat-mdc-dialog-actions { + grid-row: 4; } .api-key-text { position: relative; From fba08a32126f2ada55d8e3ae1a8655b34a3421d6 Mon Sep 17 00:00:00 2001 From: deaflynx Date: Tue, 18 Nov 2025 10:41:40 +0200 Subject: [PATCH 50/56] Refactor API key dialog components and styles. --- .../add-api-key-dialog.component.html | 0 .../add-api-key-dialog.component.scss | 0 .../add-api-key-dialog.component.ts | 2 +- .../api-key-generated-dialog.component.html | 0 .../api-key-generated-dialog.component.scss | 10 ++-- .../api-key-generated-dialog.component.ts | 0 .../api-key/api-keys-table-config.ts | 46 ++++++++++--------- .../api-keys-table-dialog.component.html | 0 .../api-keys-table-dialog.component.scss | 0 .../api-keys-table-dialog.component.ts | 0 ...t-api-key-description-panel.component.html | 0 ...t-api-key-description-panel.component.scss | 0 ...dit-api-key-description-panel.component.ts | 0 .../home/components/home-components.module.ts | 14 ++---- .../home/pages/security/security.component.ts | 5 +- 15 files changed, 40 insertions(+), 37 deletions(-) rename ui-ngx/src/app/modules/home/components/api-key/{components/dialog => }/add-api-key-dialog.component.html (100%) rename ui-ngx/src/app/modules/home/components/api-key/{components/dialog => }/add-api-key-dialog.component.scss (100%) rename ui-ngx/src/app/modules/home/components/api-key/{components/dialog => }/add-api-key-dialog.component.ts (98%) rename ui-ngx/src/app/modules/home/components/api-key/{components/dialog => }/api-key-generated-dialog.component.html (100%) rename ui-ngx/src/app/modules/home/components/api-key/{components/dialog => }/api-key-generated-dialog.component.scss (90%) rename ui-ngx/src/app/modules/home/components/api-key/{components/dialog => }/api-key-generated-dialog.component.ts (100%) rename ui-ngx/src/app/modules/home/components/api-key/{components/dialog => }/api-keys-table-dialog.component.html (100%) rename ui-ngx/src/app/modules/home/components/api-key/{components/dialog => }/api-keys-table-dialog.component.scss (100%) rename ui-ngx/src/app/modules/home/components/api-key/{components/dialog => }/api-keys-table-dialog.component.ts (100%) rename ui-ngx/src/app/modules/home/components/api-key/{components/dialog => }/edit-api-key-description-panel.component.html (100%) rename ui-ngx/src/app/modules/home/components/api-key/{components/dialog => }/edit-api-key-description-panel.component.scss (100%) rename ui-ngx/src/app/modules/home/components/api-key/{components/dialog => }/edit-api-key-description-panel.component.ts (100%) diff --git a/ui-ngx/src/app/modules/home/components/api-key/components/dialog/add-api-key-dialog.component.html b/ui-ngx/src/app/modules/home/components/api-key/add-api-key-dialog.component.html similarity index 100% rename from ui-ngx/src/app/modules/home/components/api-key/components/dialog/add-api-key-dialog.component.html rename to ui-ngx/src/app/modules/home/components/api-key/add-api-key-dialog.component.html diff --git a/ui-ngx/src/app/modules/home/components/api-key/components/dialog/add-api-key-dialog.component.scss b/ui-ngx/src/app/modules/home/components/api-key/add-api-key-dialog.component.scss similarity index 100% rename from ui-ngx/src/app/modules/home/components/api-key/components/dialog/add-api-key-dialog.component.scss rename to ui-ngx/src/app/modules/home/components/api-key/add-api-key-dialog.component.scss diff --git a/ui-ngx/src/app/modules/home/components/api-key/components/dialog/add-api-key-dialog.component.ts b/ui-ngx/src/app/modules/home/components/api-key/add-api-key-dialog.component.ts similarity index 98% rename from ui-ngx/src/app/modules/home/components/api-key/components/dialog/add-api-key-dialog.component.ts rename to ui-ngx/src/app/modules/home/components/api-key/add-api-key-dialog.component.ts index 548f2661fe..5cdc7b2026 100644 --- a/ui-ngx/src/app/modules/home/components/api-key/components/dialog/add-api-key-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/api-key/add-api-key-dialog.component.ts @@ -25,7 +25,7 @@ import { FormBuilder, Validators } from '@angular/forms'; import { deepTrim } from '@core/utils'; import { ApiKeyService } from '@core/http/api-key.service'; import { ApiKeyInfo } from '@shared/models/api-key.models'; -import { ApiKeysTableDialogData } from '@home/components/api-key/components/dialog/api-keys-table-dialog.component'; +import { ApiKeysTableDialogData } from '@home/components/api-key/api-keys-table-dialog.component'; import { DAY } from '@shared/models/time/time.models'; @Component({ diff --git a/ui-ngx/src/app/modules/home/components/api-key/components/dialog/api-key-generated-dialog.component.html b/ui-ngx/src/app/modules/home/components/api-key/api-key-generated-dialog.component.html similarity index 100% rename from ui-ngx/src/app/modules/home/components/api-key/components/dialog/api-key-generated-dialog.component.html rename to ui-ngx/src/app/modules/home/components/api-key/api-key-generated-dialog.component.html diff --git a/ui-ngx/src/app/modules/home/components/api-key/components/dialog/api-key-generated-dialog.component.scss b/ui-ngx/src/app/modules/home/components/api-key/api-key-generated-dialog.component.scss similarity index 90% rename from ui-ngx/src/app/modules/home/components/api-key/components/dialog/api-key-generated-dialog.component.scss rename to ui-ngx/src/app/modules/home/components/api-key/api-key-generated-dialog.component.scss index ecd4de8363..7122a08f23 100644 --- a/ui-ngx/src/app/modules/home/components/api-key/components/dialog/api-key-generated-dialog.component.scss +++ b/ui-ngx/src/app/modules/home/components/api-key/api-key-generated-dialog.component.scss @@ -13,6 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +@import '../../src/scss/constants'; + :host{ display: grid; width: 500px; @@ -34,8 +36,10 @@ pre[class*=language-] { margin: 0; background: #F3F6FA; - border-color: #305680; + border-color: $tb-primary-color; padding-right: 38px; + overflow: hidden; + overflow-x: auto; padding-bottom: 4px; min-height: 42px; scrollbar-width: thin; @@ -49,7 +53,7 @@ button.clipboard-btn { right: -2px; p { - color: #305680; + color: $tb-primary-color; } p, div { background-color: #F3F6FA; @@ -64,7 +68,7 @@ display: block; width: 18px; height: 18px; - background: #305680; + background: $tb-primary-color; mask-image: url(/assets/copy-code-icon.svg); -webkit-mask-image: url(/assets/copy-code-icon.svg); mask-repeat: no-repeat; diff --git a/ui-ngx/src/app/modules/home/components/api-key/components/dialog/api-key-generated-dialog.component.ts b/ui-ngx/src/app/modules/home/components/api-key/api-key-generated-dialog.component.ts similarity index 100% rename from ui-ngx/src/app/modules/home/components/api-key/components/dialog/api-key-generated-dialog.component.ts rename to ui-ngx/src/app/modules/home/components/api-key/api-key-generated-dialog.component.ts diff --git a/ui-ngx/src/app/modules/home/components/api-key/api-keys-table-config.ts b/ui-ngx/src/app/modules/home/components/api-key/api-keys-table-config.ts index daf4dbf7b2..a6b8626de2 100644 --- a/ui-ngx/src/app/modules/home/components/api-key/api-keys-table-config.ts +++ b/ui-ngx/src/app/modules/home/components/api-key/api-keys-table-config.ts @@ -17,7 +17,8 @@ import { DateEntityTableColumn, EntityTableColumn, - EntityTableConfig + EntityTableConfig, + CellActionDescriptor } from '@home/models/entity/entities-table-config.models'; import { EntityType, EntityTypeResource, entityTypeTranslations } from '@shared/models/entity-type.models'; import { Direction } from '@shared/models/page/sort-order'; @@ -32,14 +33,13 @@ import { CustomTranslatePipe } from '@shared/pipe/custom-translate.pipe'; import { TbPopoverService } from '@shared/components/popover.service'; import { map } from 'rxjs/operators'; import { UserId } from '@shared/models/id/user-id'; -import { AddApiKeyDialogComponent } from '@home/components/api-key/components/dialog/add-api-key-dialog.component'; +import { AddApiKeyDialogComponent } from '@home/components/api-key/add-api-key-dialog.component'; +import { EditApiKeyDescriptionPanelComponent } from '@home/components/api-key/edit-api-key-description-panel.component'; +import { ApiKeysTableDialogData } from '@home/components/api-key/api-keys-table-dialog.component'; import { - EditApiKeyDescriptionPanelComponent -} from '@home/components/api-key/components/dialog/edit-api-key-description-panel.component'; -import { ApiKeysTableDialogData } from '@home/components/api-key/components/dialog/api-keys-table-dialog.component'; -import { - ApiKeyGeneratedDialogComponent, ApiKeyGeneratedDialogData -} from '@home/components/api-key/components/dialog/api-key-generated-dialog.component'; + ApiKeyGeneratedDialogComponent, + ApiKeyGeneratedDialogData +} from '@home/components/api-key/api-key-generated-dialog.component'; @Injectable() export class ApiKeysTableConfig extends EntityTableConfig { @@ -65,6 +65,7 @@ export class ApiKeysTableConfig extends EntityTableConfig { this.entityTranslations = entityTypeTranslations.get(EntityType.API_KEY); this.entityResources = {} as EntityTypeResource; this.tableTitle = this.translate.instant('api-key.api-keys'); + this.defaultSortOrder = {property: 'createdTime', direction: Direction.DESC}; this.entitiesFetchFunction = pageLink => this.apiKeyService.getUserApiKeys(this.userId.id, pageLink); this.addEntity = () => this.addApiKey(); @@ -75,18 +76,7 @@ export class ApiKeysTableConfig extends EntityTableConfig { this.deleteEntitiesContent = () => this.translate.instant('api-key.delete-api-keys-text'); this.deleteEntity = id => this.apiKeyService.deleteApiKey(id.id); - this.cellActionDescriptors = [{ - name: '', - nameFunction: (entity) => - this.translate.instant(entity.enabled ? 'api-key.disable' : 'api-key.enable'), - icon: 'mdi:toggle-switch', - isEnabled: (entity) => !entity.expired, - iconFunction: (entity) => entity.enabled ? 'mdi:toggle-switch' : 'mdi:toggle-switch-off-outline', - onAction: ($event, entity) => this.toggleEnableMode($event, entity) - }]; - - this.defaultSortOrder = {property: 'createdTime', direction: Direction.DESC}; - + this.cellActionDescriptors = this.configureCellActions(); this.columns.push( new DateEntityTableColumn('createdTime', 'common.created-time', this.datePipe, '170px'), new EntityTableColumn('description', 'api-key.description', '100%', @@ -108,6 +98,21 @@ export class ApiKeysTableConfig extends EntityTableConfig { ); } + private configureCellActions(): Array> { + const actions: Array> = []; + actions.push( + { + name: '', + nameFunction: (entity) => this.translate.instant(entity.enabled ? 'api-key.disable' : 'api-key.enable'), + icon: 'mdi:toggle-switch', + isEnabled: (entity) => !entity.expired, + iconFunction: (entity) => entity.enabled ? 'mdi:toggle-switch' : 'mdi:toggle-switch-off-outline', + onAction: ($event, entity) => this.toggleEnableMode($event, entity) + } + ) + return actions; + } + private addApiKey(): Observable { return this.dialog.open(AddApiKeyDialogComponent, { disableClose: true, @@ -137,7 +142,6 @@ export class ApiKeysTableConfig extends EntityTableConfig { }); } - private toggleEnableMode($event: Event, entity: ApiKeyInfo): void { if ($event) { $event.stopPropagation(); diff --git a/ui-ngx/src/app/modules/home/components/api-key/components/dialog/api-keys-table-dialog.component.html b/ui-ngx/src/app/modules/home/components/api-key/api-keys-table-dialog.component.html similarity index 100% rename from ui-ngx/src/app/modules/home/components/api-key/components/dialog/api-keys-table-dialog.component.html rename to ui-ngx/src/app/modules/home/components/api-key/api-keys-table-dialog.component.html diff --git a/ui-ngx/src/app/modules/home/components/api-key/components/dialog/api-keys-table-dialog.component.scss b/ui-ngx/src/app/modules/home/components/api-key/api-keys-table-dialog.component.scss similarity index 100% rename from ui-ngx/src/app/modules/home/components/api-key/components/dialog/api-keys-table-dialog.component.scss rename to ui-ngx/src/app/modules/home/components/api-key/api-keys-table-dialog.component.scss diff --git a/ui-ngx/src/app/modules/home/components/api-key/components/dialog/api-keys-table-dialog.component.ts b/ui-ngx/src/app/modules/home/components/api-key/api-keys-table-dialog.component.ts similarity index 100% rename from ui-ngx/src/app/modules/home/components/api-key/components/dialog/api-keys-table-dialog.component.ts rename to ui-ngx/src/app/modules/home/components/api-key/api-keys-table-dialog.component.ts diff --git a/ui-ngx/src/app/modules/home/components/api-key/components/dialog/edit-api-key-description-panel.component.html b/ui-ngx/src/app/modules/home/components/api-key/edit-api-key-description-panel.component.html similarity index 100% rename from ui-ngx/src/app/modules/home/components/api-key/components/dialog/edit-api-key-description-panel.component.html rename to ui-ngx/src/app/modules/home/components/api-key/edit-api-key-description-panel.component.html diff --git a/ui-ngx/src/app/modules/home/components/api-key/components/dialog/edit-api-key-description-panel.component.scss b/ui-ngx/src/app/modules/home/components/api-key/edit-api-key-description-panel.component.scss similarity index 100% rename from ui-ngx/src/app/modules/home/components/api-key/components/dialog/edit-api-key-description-panel.component.scss rename to ui-ngx/src/app/modules/home/components/api-key/edit-api-key-description-panel.component.scss diff --git a/ui-ngx/src/app/modules/home/components/api-key/components/dialog/edit-api-key-description-panel.component.ts b/ui-ngx/src/app/modules/home/components/api-key/edit-api-key-description-panel.component.ts similarity index 100% rename from ui-ngx/src/app/modules/home/components/api-key/components/dialog/edit-api-key-description-panel.component.ts rename to ui-ngx/src/app/modules/home/components/api-key/edit-api-key-description-panel.component.ts diff --git a/ui-ngx/src/app/modules/home/components/home-components.module.ts b/ui-ngx/src/app/modules/home/components/home-components.module.ts index cfafe70a96..2418eb9ad2 100644 --- a/ui-ngx/src/app/modules/home/components/home-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/home-components.module.ts @@ -198,16 +198,10 @@ import { CalculatedFieldsModule } from '@home/components/calculated-fields/calcu import { AlarmRuleModule } from "@home/components/alarm-rules/alarm-rule.module"; import { AlarmRulesTableComponent } from "@home/components/alarm-rules/alarm-rules-table.component"; import { ApiKeysTableComponent } from '@home/components/api-key/api-keys-table.component'; -import { AddApiKeyDialogComponent } from '@home/components/api-key/components/dialog/add-api-key-dialog.component'; -import { - EditApiKeyDescriptionPanelComponent -} from '@home/components/api-key/components/dialog/edit-api-key-description-panel.component'; -import { - ApiKeyGeneratedDialogComponent -} from '@home/components/api-key/components/dialog/api-key-generated-dialog.component'; -import { - ApiKeysTableDialogComponent -} from '@home/components/api-key/components/dialog/api-keys-table-dialog.component'; +import { AddApiKeyDialogComponent } from '@home/components/api-key/add-api-key-dialog.component'; +import { EditApiKeyDescriptionPanelComponent } from '@home/components/api-key/edit-api-key-description-panel.component'; +import { ApiKeyGeneratedDialogComponent } from '@home/components/api-key/api-key-generated-dialog.component'; +import { ApiKeysTableDialogComponent } from '@home/components/api-key/api-keys-table-dialog.component'; @NgModule({ declarations: diff --git a/ui-ngx/src/app/modules/home/pages/security/security.component.ts b/ui-ngx/src/app/modules/home/pages/security/security.component.ts index 026d798e36..daf83186de 100644 --- a/ui-ngx/src/app/modules/home/pages/security/security.component.ts +++ b/ui-ngx/src/app/modules/home/pages/security/security.component.ts @@ -53,8 +53,9 @@ import { AuthService } from '@core/auth/auth.service'; import { UserPasswordPolicy } from '@shared/models/settings.models'; import { MatCheckboxChange } from '@angular/material/checkbox'; import { - ApiKeysTableDialogComponent, ApiKeysTableDialogData -} from '@home/components/api-key/components/dialog/api-keys-table-dialog.component'; + ApiKeysTableDialogComponent, + ApiKeysTableDialogData +} from '@home/components/api-key/api-keys-table-dialog.component'; @Component({ selector: 'tb-security', From ce92740f1f1ad99b08b1b7229e47510539af9884 Mon Sep 17 00:00:00 2001 From: IrynaMatveieva Date: Tue, 18 Nov 2025 10:51:54 +0200 Subject: [PATCH 51/56] review fixes --- ...tractCalculatedFieldProcessingService.java | 12 ++-- .../cf/CalculatedFieldProcessingService.java | 4 -- ...faultCalculatedFieldProcessingService.java | 6 +- ...=> AttributesImmediateOutputStrategy.java} | 2 +- .../cf/configuration/AttributesOutput.java | 4 +- ...egy.java => AttributesOutputStrategy.java} | 6 +- ...=> AttributesRuleChainOutputStrategy.java} | 2 +- .../thingsboard/server/dao/util/KvUtils.java | 37 ++++++++++++ .../engine/telemetry/TbMsgAttributesNode.java | 2 +- .../engine/telemetry/TbMsgTimeseriesNode.java | 4 +- .../rule/engine/util/TelemetryUtil.java | 60 ------------------- 11 files changed, 54 insertions(+), 85 deletions(-) rename common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/{AttributeImmediateOutputStrategy.java => AttributesImmediateOutputStrategy.java} (92%) rename common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/{AttributeOutputStrategy.java => AttributesOutputStrategy.java} (79%) rename common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/{AttributeRuleChainOutputStrategy.java => AttributesRuleChainOutputStrategy.java} (91%) delete mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TelemetryUtil.java diff --git a/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java b/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java index 39ab78f86a..d44d06407c 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java @@ -37,7 +37,7 @@ import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentType; -import org.thingsboard.server.common.data.cf.configuration.AttributeImmediateOutputStrategy; +import org.thingsboard.server.common.data.cf.configuration.AttributesImmediateOutputStrategy; import org.thingsboard.server.common.data.cf.configuration.OutputStrategy; import org.thingsboard.server.common.data.cf.configuration.OutputType; import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration; @@ -79,12 +79,12 @@ import java.util.concurrent.ExecutionException; import java.util.function.Predicate; import java.util.stream.Collectors; -import static org.thingsboard.rule.engine.util.TelemetryUtil.filterChangedAttr; -import static org.thingsboard.rule.engine.util.TelemetryUtil.toTsKvEntryList; import static org.thingsboard.server.common.data.cf.CalculatedFieldType.PROPAGATION; import static org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration.PROPAGATION_CONFIG_ARGUMENT; import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY; import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LONGITUDE_ARGUMENT_KEY; +import static org.thingsboard.server.dao.util.KvUtils.filterChangedAttr; +import static org.thingsboard.server.dao.util.KvUtils.toTsKvEntryList; import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.createDefaultAttributeEntry; import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.createDefaultKvEntry; import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.transformSingleValueArgument; @@ -379,7 +379,7 @@ public abstract class AbstractCalculatedFieldProcessingService { } private void saveAttributes(TenantId tenantId, EntityId entityId, JsonElement jsonResult, OutputStrategy outputStrategy, AttributeScope scope, List cfIds, SettableFuture future) { - if (!(outputStrategy instanceof AttributeImmediateOutputStrategy attOutputStrategy)) { + if (!(outputStrategy instanceof AttributesImmediateOutputStrategy attOutputStrategy)) { future.setException(new IllegalArgumentException("Only AttributeImmediateOutputStrategy is supported.")); } else { AttributesSaveRequest.Strategy strategy = new Strategy(attOutputStrategy.isSaveAttribute(), attOutputStrategy.isSendWsUpdate(), attOutputStrategy.isProcessCfs()); @@ -413,7 +413,7 @@ public abstract class AbstractCalculatedFieldProcessingService { List entries, AttributesSaveRequest.Strategy strategy, SettableFuture future) { - tsSubService.saveAttributesInternal(AttributesSaveRequest.builder() + tsSubService.saveAttributes(AttributesSaveRequest.builder() .tenantId(tenantId) .entityId(entityId) .scope(scope) @@ -452,7 +452,7 @@ public abstract class AbstractCalculatedFieldProcessingService { if (cfIds != null && !cfIds.isEmpty()) { builder.previousCalculatedFieldIds(cfIds); } - tsSubService.saveTimeseriesInternal(builder.build()); + tsSubService.saveTimeseries(builder.build()); } } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldProcessingService.java b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldProcessingService.java index 4a6fc08b57..858f9eb2f3 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldProcessingService.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldProcessingService.java @@ -39,12 +39,8 @@ public interface CalculatedFieldProcessingService { Map fetchArgsFromDb(TenantId tenantId, EntityId entityId, Map arguments); - void processImmediately(TenantId tenantId, EntityId entityId, CalculatedFieldResult result, List cfIds, TbCallback callback); - void processResult(TenantId tenantId, EntityId entityId, CalculatedFieldResult result, List cfIds, TbCallback callback); - void pushMsgToRuleEngine(TenantId tenantId, EntityId entityId, CalculatedFieldResult result, List cfIds, TbCallback callback); - void pushMsgToLinks(CalculatedFieldTelemetryMsg msg, List linkedCalculatedFields, TbCallback callback); } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java index 302f51ecb1..851717326f 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java @@ -143,8 +143,7 @@ public class DefaultCalculatedFieldProcessingService extends AbstractCalculatedF } } - @Override - public void processImmediately(TenantId tenantId, EntityId entityId, CalculatedFieldResult result, List cfIds, TbCallback callback) { + private void processImmediately(TenantId tenantId, EntityId entityId, CalculatedFieldResult result, List cfIds, TbCallback callback) { if (result instanceof TelemetryCalculatedFieldResult telemetryResult) { saveTelemetryResult(tenantId, entityId, telemetryResult, cfIds, callback); return; @@ -157,8 +156,7 @@ public class DefaultCalculatedFieldProcessingService extends AbstractCalculatedF callback.onSuccess(); } - @Override - public void pushMsgToRuleEngine(TenantId tenantId, EntityId entityId, CalculatedFieldResult result, List cfIds, TbCallback callback) { + private void pushMsgToRuleEngine(TenantId tenantId, EntityId entityId, CalculatedFieldResult result, List cfIds, TbCallback callback) { if (result instanceof PropagationCalculatedFieldResult propagationResult) { handlePropagationResults(propagationResult, callback, (entity, res, cb) -> sendMsgToRuleEngine(tenantId, entityId, cb, res.toTbMsg(entity, cfIds))); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributeImmediateOutputStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributesImmediateOutputStrategy.java similarity index 92% rename from common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributeImmediateOutputStrategy.java rename to common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributesImmediateOutputStrategy.java index 737c4fc64e..73bc65274d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributeImmediateOutputStrategy.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributesImmediateOutputStrategy.java @@ -22,7 +22,7 @@ import lombok.NoArgsConstructor; @Data @AllArgsConstructor @NoArgsConstructor -public class AttributeImmediateOutputStrategy implements AttributeOutputStrategy { +public class AttributesImmediateOutputStrategy implements AttributesOutputStrategy { private boolean updateAttributesOnlyOnValueChange; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributesOutput.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributesOutput.java index 61195fc5f4..578af5c6ea 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributesOutput.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributesOutput.java @@ -25,10 +25,10 @@ public class AttributesOutput implements Output { private AttributeScope scope; private Integer decimalsByDefault; - private AttributeOutputStrategy strategy; + private AttributesOutputStrategy strategy; public AttributesOutput() { - this.strategy = new AttributeRuleChainOutputStrategy(); + this.strategy = new AttributesRuleChainOutputStrategy(); } @Override diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributeOutputStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributesOutputStrategy.java similarity index 79% rename from common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributeOutputStrategy.java rename to common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributesOutputStrategy.java index f47bc27579..057fb7d8d7 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributeOutputStrategy.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributesOutputStrategy.java @@ -26,8 +26,8 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo; property = "type" ) @JsonSubTypes({ - @JsonSubTypes.Type(value = AttributeImmediateOutputStrategy.class, name = "IMMEDIATE"), - @JsonSubTypes.Type(value = AttributeRuleChainOutputStrategy.class, name = "RULE_CHAIN"), + @JsonSubTypes.Type(value = AttributesImmediateOutputStrategy.class, name = "IMMEDIATE"), + @JsonSubTypes.Type(value = AttributesRuleChainOutputStrategy.class, name = "RULE_CHAIN"), }) -public interface AttributeOutputStrategy extends OutputStrategy { +public interface AttributesOutputStrategy extends OutputStrategy { } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributeRuleChainOutputStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributesRuleChainOutputStrategy.java similarity index 91% rename from common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributeRuleChainOutputStrategy.java rename to common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributesRuleChainOutputStrategy.java index ce03aeb750..1a3348ce74 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributeRuleChainOutputStrategy.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributesRuleChainOutputStrategy.java @@ -20,7 +20,7 @@ import lombok.NoArgsConstructor; @Data @NoArgsConstructor -public class AttributeRuleChainOutputStrategy implements AttributeOutputStrategy { +public class AttributesRuleChainOutputStrategy implements AttributesOutputStrategy { @Override public OutputStrategyType getType() { diff --git a/dao/src/main/java/org/thingsboard/server/dao/util/KvUtils.java b/dao/src/main/java/org/thingsboard/server/dao/util/KvUtils.java index 8b95ddcb57..92f65c6e6d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/util/KvUtils.java +++ b/dao/src/main/java/org/thingsboard/server/dao/util/KvUtils.java @@ -19,13 +19,21 @@ import com.fasterxml.jackson.databind.JsonNode; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.BasicTsKvEntry; import org.thingsboard.server.common.data.kv.KvEntry; +import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.exception.IncorrectParameterException; import org.thingsboard.server.dao.service.NoXssValidator; +import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.Objects; import java.util.concurrent.TimeUnit; +import java.util.function.Function; +import java.util.stream.Collectors; public class KvUtils { @@ -74,4 +82,33 @@ public class KvUtils { } } } + + public static List toTsKvEntryList(Map> tsKvMap) { + List tsKvEntryList = new ArrayList<>(); + for (Map.Entry> tsKvEntry : tsKvMap.entrySet()) { + for (KvEntry kvEntry : tsKvEntry.getValue()) { + tsKvEntryList.add(new BasicTsKvEntry(tsKvEntry.getKey(), kvEntry)); + } + } + return tsKvEntryList; + } + + public static List filterChangedAttr(List currentAttributes, List newAttributes) { + if (currentAttributes == null || currentAttributes.isEmpty()) { + return newAttributes; + } + + Map currentAttrMap = currentAttributes.stream() + .collect(Collectors.toMap(AttributeKvEntry::getKey, Function.identity(), (existing, replacement) -> existing)); + + return newAttributes.stream() + .filter(item -> { + AttributeKvEntry cacheAttr = currentAttrMap.get(item.getKey()); + return cacheAttr == null + || !Objects.equals(item.getValue(), cacheAttr.getValue()) //JSON and String can be equals by value, but different by type + || !Objects.equals(item.getDataType(), cacheAttr.getDataType()); + }) + .collect(Collectors.toList()); + } + } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNode.java index 20aa7993a1..0c73efa1b9 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNode.java @@ -48,10 +48,10 @@ import static org.thingsboard.rule.engine.telemetry.settings.AttributesProcessin import static org.thingsboard.rule.engine.telemetry.settings.AttributesProcessingSettings.Deduplicate; import static org.thingsboard.rule.engine.telemetry.settings.AttributesProcessingSettings.OnEveryMessage; import static org.thingsboard.rule.engine.telemetry.settings.AttributesProcessingSettings.WebSocketsOnly; -import static org.thingsboard.rule.engine.util.TelemetryUtil.filterChangedAttr; import static org.thingsboard.server.common.data.DataConstants.NOTIFY_DEVICE_METADATA_KEY; import static org.thingsboard.server.common.data.DataConstants.SCOPE; import static org.thingsboard.server.common.data.msg.TbMsgType.POST_ATTRIBUTES_REQUEST; +import static org.thingsboard.server.dao.util.KvUtils.filterChangedAttr; @RuleNode( type = ComponentType.ACTION, diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgTimeseriesNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgTimeseriesNode.java index 32f06b1e00..80e964e893 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgTimeseriesNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgTimeseriesNode.java @@ -31,7 +31,6 @@ import org.thingsboard.rule.engine.telemetry.strategy.ProcessingStrategy; import org.thingsboard.server.common.adaptor.JsonConverter; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.TenantProfile; -import org.thingsboard.server.common.data.kv.BasicTsKvEntry; import org.thingsboard.server.common.data.kv.KvEntry; import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.plugin.ComponentType; @@ -39,7 +38,6 @@ import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileCon import org.thingsboard.server.common.data.util.TbPair; import org.thingsboard.server.common.msg.TbMsg; -import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.UUID; @@ -49,8 +47,8 @@ import static org.thingsboard.rule.engine.telemetry.settings.TimeseriesProcessin import static org.thingsboard.rule.engine.telemetry.settings.TimeseriesProcessingSettings.Deduplicate; import static org.thingsboard.rule.engine.telemetry.settings.TimeseriesProcessingSettings.OnEveryMessage; import static org.thingsboard.rule.engine.telemetry.settings.TimeseriesProcessingSettings.WebSocketsOnly; -import static org.thingsboard.rule.engine.util.TelemetryUtil.toTsKvEntryList; import static org.thingsboard.server.common.data.msg.TbMsgType.POST_TELEMETRY_REQUEST; +import static org.thingsboard.server.dao.util.KvUtils.toTsKvEntryList; @RuleNode( type = ComponentType.ACTION, diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TelemetryUtil.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TelemetryUtil.java deleted file mode 100644 index 41d6f1ce1d..0000000000 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TelemetryUtil.java +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Copyright © 2016-2025 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.rule.engine.util; - -import org.thingsboard.server.common.data.kv.AttributeKvEntry; -import org.thingsboard.server.common.data.kv.BasicTsKvEntry; -import org.thingsboard.server.common.data.kv.KvEntry; -import org.thingsboard.server.common.data.kv.TsKvEntry; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.function.Function; -import java.util.stream.Collectors; - -public class TelemetryUtil { - - public static List toTsKvEntryList(Map> tsKvMap) { - List tsKvEntryList = new ArrayList<>(); - for (Map.Entry> tsKvEntry : tsKvMap.entrySet()) { - for (KvEntry kvEntry : tsKvEntry.getValue()) { - tsKvEntryList.add(new BasicTsKvEntry(tsKvEntry.getKey(), kvEntry)); - } - } - return tsKvEntryList; - } - - public static List filterChangedAttr(List currentAttributes, List newAttributes) { - if (currentAttributes == null || currentAttributes.isEmpty()) { - return newAttributes; - } - - Map currentAttrMap = currentAttributes.stream() - .collect(Collectors.toMap(AttributeKvEntry::getKey, Function.identity(), (existing, replacement) -> existing)); - - return newAttributes.stream() - .filter(item -> { - AttributeKvEntry cacheAttr = currentAttrMap.get(item.getKey()); - return cacheAttr == null - || !Objects.equals(item.getValue(), cacheAttr.getValue()) //JSON and String can be equals by value, but different by type - || !Objects.equals(item.getDataType(), cacheAttr.getDataType()); - }) - .collect(Collectors.toList()); - } - -} From f45b03a1b4e1c2fbb5ea14cc668be9b0bf20f57c Mon Sep 17 00:00:00 2001 From: IrynaMatveieva Date: Tue, 18 Nov 2025 12:36:42 +0200 Subject: [PATCH 52/56] apply output strategy to entity aggregation cf --- .../single/EntityAggregationCalculatedFieldState.java | 1 + .../server/cf/EntityAggregationCalculatedFieldTest.java | 5 ++--- ...EntityAggregationCalculatedFieldConfigurationTest.java | 8 ++++---- .../entity-aggregation-component.component.ts | 6 ++---- 4 files changed, 9 insertions(+), 11 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldState.java index b07600695c..520882fa75 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldState.java @@ -105,6 +105,7 @@ public class EntityAggregationCalculatedFieldState extends BaseCalculatedFieldSt return Futures.immediateFuture(TelemetryCalculatedFieldResult.EMPTY); } return Futures.immediateFuture(TelemetryCalculatedFieldResult.builder() + .outputStrategy(output.getStrategy()) .type(output.getType()) .scope(output.getScope()) .result(result) diff --git a/application/src/test/java/org/thingsboard/server/cf/EntityAggregationCalculatedFieldTest.java b/application/src/test/java/org/thingsboard/server/cf/EntityAggregationCalculatedFieldTest.java index e479c4959e..cf2c558618 100644 --- a/application/src/test/java/org/thingsboard/server/cf/EntityAggregationCalculatedFieldTest.java +++ b/application/src/test/java/org/thingsboard/server/cf/EntityAggregationCalculatedFieldTest.java @@ -29,8 +29,8 @@ import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentType; import org.thingsboard.server.common.data.cf.configuration.Output; -import org.thingsboard.server.common.data.cf.configuration.OutputType; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; +import org.thingsboard.server.common.data.cf.configuration.TimeSeriesOutput; import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction; import org.thingsboard.server.common.data.cf.configuration.aggregation.AggKeyInput; import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric; @@ -209,8 +209,7 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest consumption.setDefaultValue(9999L); aggMetrics.put("consumption", consumption); - Output output = new Output(); - output.setType(OutputType.TIME_SERIES); + TimeSeriesOutput output = new TimeSeriesOutput(); output.setDecimalsByDefault(0); return createAggCf("Consumption per minute", entityId, diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/EntityAggregationCalculatedFieldConfigurationTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/EntityAggregationCalculatedFieldConfigurationTest.java index 3884b5a214..9311bcead7 100644 --- a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/EntityAggregationCalculatedFieldConfigurationTest.java +++ b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/EntityAggregationCalculatedFieldConfigurationTest.java @@ -21,8 +21,8 @@ import org.junit.jupiter.params.provider.ValueSource; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentType; -import org.thingsboard.server.common.data.cf.configuration.Output; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; +import org.thingsboard.server.common.data.cf.configuration.TimeSeriesOutput; import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunctionInput; import org.thingsboard.server.common.data.cf.configuration.aggregation.AggKeyInput; import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric; @@ -74,7 +74,7 @@ public class EntityAggregationCalculatedFieldConfigurationTest { cfg.setMetrics(Map.of("m", metric)); cfg.setInterval(new HourInterval("Europe/Kiev", null)); - cfg.setOutput(new Output()); + cfg.setOutput(new TimeSeriesOutput()); assertThatThrownBy(cfg::validate) .isInstanceOf(IllegalArgumentException.class) @@ -92,7 +92,7 @@ public class EntityAggregationCalculatedFieldConfigurationTest { cfg.setMetrics(Map.of("m", metric)); cfg.setInterval(new HourInterval("Europe/Kiev", null)); - cfg.setOutput(new Output()); + cfg.setOutput(new TimeSeriesOutput()); assertThatThrownBy(cfg::validate) .isInstanceOf(IllegalArgumentException.class) @@ -106,7 +106,7 @@ public class EntityAggregationCalculatedFieldConfigurationTest { cfg.setArguments(Map.of("k", validArgument(ArgumentType.TS_LATEST))); cfg.setMetrics(Map.of("m", validMetric())); cfg.setInterval(null); - cfg.setOutput(new Output()); + cfg.setOutput(new TimeSeriesOutput()); assertThatThrownBy(cfg::validate) .isInstanceOf(IllegalArgumentException.class) diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.ts index 5d612704a0..c0afae1c30 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.ts @@ -32,8 +32,8 @@ import { CalculatedFieldEntityAggregationConfiguration, CalculatedFieldOutput, CalculatedFieldType, + defaultCalculatedFieldOutput, notEmptyObjectValidator, - OutputType } from '@shared/models/calculated-field.models'; import { filter, map } from 'rxjs/operators'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @@ -102,9 +102,7 @@ export class EntityAggregationComponentComponent implements ControlValueAccessor watermark: this.fb.group({ duration: [HOUR/SECOND, Validators.required], }), - output: this.fb.control({ - type: OutputType.Timeseries, - }), + output: this.fb.control(defaultCalculatedFieldOutput), }); arguments$ = this.entityAggregationConfiguration.get('arguments').valueChanges.pipe( From d8064aeacc48a3d58aaec23e5fb6ea0907fc209c Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Tue, 18 Nov 2025 14:21:57 +0200 Subject: [PATCH 53/56] Minor changes for api key to work properly with get sysadmin --- .../server/controller/ApiKeyController.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java b/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java index 4078f7855f..efe83f6949 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java +++ b/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java @@ -30,6 +30,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.ApiKeyId; import org.thingsboard.server.common.data.id.UserId; @@ -73,11 +74,10 @@ public class ApiKeyController extends BaseController { public ApiKey saveApiKey( @Parameter(description = "A JSON value representing the Api Key token.") @RequestBody @Valid ApiKeyInfo apiKeyInfo) throws ThingsboardException { - SecurityUser securityUser = getCurrentUser(); - apiKeyInfo.setTenantId(securityUser.getTenantId()); + User user = checkUserId(apiKeyInfo.getUserId(), Operation.WRITE); + apiKeyInfo.setTenantId(user.getTenantId()); checkEntity(apiKeyInfo.getId(), apiKeyInfo, Resource.API_KEY); - checkUserId(apiKeyInfo.getUserId(), Operation.WRITE); - return checkNotNull(apiKeyService.saveApiKey(securityUser.getTenantId(), apiKeyInfo)); + return checkNotNull(apiKeyService.saveApiKey(apiKeyInfo.getTenantId(), apiKeyInfo)); } @ApiOperation(value = "Get User Api Keys (getUserApiKeys)", @@ -102,8 +102,8 @@ public class ApiKeyController extends BaseController { PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); UserId userId = new UserId(toUUID(userIdStr)); accessControlService.checkPermission(securityUser, Resource.API_KEY, Operation.READ); - checkUserId(userId, Operation.READ); - return apiKeyService.findApiKeysByUserId(securityUser.getTenantId(), userId, pageLink); + User user = checkUserId(userId, Operation.READ); + return apiKeyService.findApiKeysByUserId(user.getTenantId(), userId, pageLink); } @ApiOperation(value = "Update API key Description", From 963384c085f58bdd6581d8cc43a29f9355b5bcb5 Mon Sep 17 00:00:00 2001 From: Vladyslav Prykhodko Date: Tue, 18 Nov 2025 15:47:38 +0200 Subject: [PATCH 54/56] Update add-api-key-dialog.component.html --- .../home/components/api-key/add-api-key-dialog.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui-ngx/src/app/modules/home/components/api-key/add-api-key-dialog.component.html b/ui-ngx/src/app/modules/home/components/api-key/add-api-key-dialog.component.html index ce53d58e0f..dd91707449 100644 --- a/ui-ngx/src/app/modules/home/components/api-key/add-api-key-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/api-key/add-api-key-dialog.component.html @@ -15,7 +15,7 @@ limitations under the License. --> - +

{{ 'api-key.generate-title' | translate }}

From 55427df98a76c730e2c5d6cca79e1bbaa7e92a35 Mon Sep 17 00:00:00 2001 From: Vladyslav Prykhodko Date: Tue, 18 Nov 2025 15:48:20 +0200 Subject: [PATCH 55/56] Update api-key-generated-dialog.component.html --- .../components/api-key/api-key-generated-dialog.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui-ngx/src/app/modules/home/components/api-key/api-key-generated-dialog.component.html b/ui-ngx/src/app/modules/home/components/api-key/api-key-generated-dialog.component.html index 1dee649945..82cd665f36 100644 --- a/ui-ngx/src/app/modules/home/components/api-key/api-key-generated-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/api-key/api-key-generated-dialog.component.html @@ -15,7 +15,7 @@ limitations under the License. --> - +

{{ 'api-key.generated-api-key-title' | translate }}