diff --git a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/controller/NoticeConfigController.java b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/controller/NoticeConfigController.java index 7a349284ff..151fc0b12f 100644 --- a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/controller/NoticeConfigController.java +++ b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/controller/NoticeConfigController.java @@ -30,6 +30,7 @@ import org.apache.hertzbeat.common.entity.alerter.NoticeReceiver; import org.apache.hertzbeat.common.entity.alerter.NoticeRule; import org.apache.hertzbeat.common.entity.alerter.NoticeTemplate; import org.apache.hertzbeat.alert.service.NoticeConfigService; +import org.apache.hertzbeat.alert.util.NoticeReceiverMaskUtil; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; import org.springframework.http.ResponseEntity; @@ -87,14 +88,16 @@ public class NoticeConfigController { @Parameter(description = "en: Recipient name,support fuzzy query", example = "tom") @RequestParam(required = false) final String name, @Parameter(description = "en: List current page", example = "0") @RequestParam(defaultValue = "0") final int pageIndex, @Parameter(description = "en: Number of list pages", example = "8") @RequestParam(defaultValue = "8") final int pageSize) { - return ResponseEntity.ok(Message.success(noticeConfigService.getNoticeReceivers(name, pageIndex, pageSize))); + return ResponseEntity.ok(Message.success(noticeConfigService.getNoticeReceivers(name, pageIndex, pageSize) + .map(NoticeReceiverMaskUtil::mask))); } @GetMapping(path = "/receivers/all") @Operation(summary = "Get a list of all message notification recipients", description = "Get a list of all message notification recipients") public ResponseEntity>> getAllReceivers() { - return ResponseEntity.ok(Message.success(noticeConfigService.getAllNoticeReceivers())); + return ResponseEntity.ok(Message.success(noticeConfigService.getAllNoticeReceivers().stream() + .map(NoticeReceiverMaskUtil::mask).toList())); } @GetMapping(path = "/receiver/{id}") @@ -106,7 +109,7 @@ public class NoticeConfigController { if (noticeReceiver == null) { return ResponseEntity.ok(Message.fail(FAIL_CODE, "The relevant information of the recipient could not be found, please check whether the parameters are correct or refresh the page")); } - return ResponseEntity.ok(Message.success(noticeReceiver)); + return ResponseEntity.ok(Message.success(NoticeReceiverMaskUtil.mask(noticeReceiver))); } @PostMapping(path = "/rule") diff --git a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/NoticeConfigServiceImpl.java b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/NoticeConfigServiceImpl.java index 24a719b5bf..2a16f39ab3 100644 --- a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/NoticeConfigServiceImpl.java +++ b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/NoticeConfigServiceImpl.java @@ -31,6 +31,7 @@ import org.apache.hertzbeat.alert.dao.NoticeReceiverDao; import org.apache.hertzbeat.alert.dao.NoticeRuleDao; import org.apache.hertzbeat.alert.dao.NoticeTemplateDao; import org.apache.hertzbeat.alert.service.NoticeConfigService; +import org.apache.hertzbeat.alert.util.NoticeReceiverMaskUtil; import org.apache.hertzbeat.common.entity.alerter.SingleAlert; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.CommandLineRunner; @@ -176,9 +177,23 @@ public class NoticeConfigServiceImpl implements NoticeConfigService, CommandLine @Override public void editReceiver(NoticeReceiver noticeReceiver) { + resolveMaskedSecrets(noticeReceiver); noticeReceiverDao.save(noticeReceiver); } + /** + * The rest api returns receivers with masked secret fields, so a receiver submitted + * from the ui may carry the mask placeholder instead of the real secret. + * Restore such fields from the stored entity before using the receiver. + */ + private void resolveMaskedSecrets(NoticeReceiver noticeReceiver) { + if (noticeReceiver == null || noticeReceiver.getId() == null) { + return; + } + noticeReceiverDao.findById(noticeReceiver.getId()) + .ifPresent(existing -> NoticeReceiverMaskUtil.resolveMask(noticeReceiver, existing)); + } + @Override public void deleteReceiver(Long receiverId) { noticeReceiverDao.deleteById(receiverId); @@ -319,6 +334,7 @@ public class NoticeConfigServiceImpl implements NoticeConfigService, CommandLine @Override public boolean sendTestMsg(NoticeReceiver noticeReceiver) { + resolveMaskedSecrets(noticeReceiver); Map labels = new HashMap<>(8); labels.put(CommonConstants.LABEL_INSTANCE, "127.0.0.1"); labels.put(CommonConstants.LABEL_ALERT_NAME, "CPU Usage Alert"); diff --git a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/util/NoticeReceiverMaskUtil.java b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/util/NoticeReceiverMaskUtil.java new file mode 100644 index 0000000000..db5fe30a43 --- /dev/null +++ b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/util/NoticeReceiverMaskUtil.java @@ -0,0 +1,125 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.apache.hertzbeat.alert.util; + +import java.util.List; +import java.util.function.BiConsumer; +import java.util.function.Function; +import org.apache.commons.lang3.StringUtils; +import org.apache.hertzbeat.common.entity.alerter.NoticeReceiver; + +/** + * Masks the secret fields of {@link NoticeReceiver} before it is exposed through the rest api, + * and resolves masked values back to the stored secrets when an edited receiver is submitted. + * A secret long enough keeps its last characters visible so different tokens stay + * distinguishable in the ui, while an unchanged secret round-trips through the ui + * without ever leaving the server. + */ +public final class NoticeReceiverMaskUtil { + + /** + * Fixed-length placeholder replacing the hidden part of a secret. Its length is + * constant on purpose so the mask never reveals how long the real secret is. + */ + public static final String SECRET_MASK = "******"; + + /** + * A secret shorter than this is masked entirely: revealing a suffix of a short + * secret would give away too large a fraction of it. + */ + private static final int MIN_LENGTH_TO_SHOW_SUFFIX = 12; + + /** + * Number of trailing characters kept visible for a long secret, + * enough to tell configured tokens apart. + */ + private static final int VISIBLE_SUFFIX_LENGTH = 4; + + private record SecretField(Function getter, BiConsumer setter) { + } + + private static final List SECRET_FIELDS = List.of( + new SecretField(NoticeReceiver::getHookAuthToken, NoticeReceiver::setHookAuthToken), + new SecretField(NoticeReceiver::getAccessToken, NoticeReceiver::setAccessToken), + new SecretField(NoticeReceiver::getTgBotToken, NoticeReceiver::setTgBotToken), + new SecretField(NoticeReceiver::getSlackWebHookUrl, NoticeReceiver::setSlackWebHookUrl), + new SecretField(NoticeReceiver::getAppSecret, NoticeReceiver::setAppSecret), + new SecretField(NoticeReceiver::getDiscordBotToken, NoticeReceiver::setDiscordBotToken), + new SecretField(NoticeReceiver::getSmnAk, NoticeReceiver::setSmnAk), + new SecretField(NoticeReceiver::getSmnSk, NoticeReceiver::setSmnSk), + new SecretField(NoticeReceiver::getServerChanToken, NoticeReceiver::setServerChanToken), + new SecretField(NoticeReceiver::getGotifyToken, NoticeReceiver::setGotifyToken), + new SecretField(NoticeReceiver::getNtfyToken, NoticeReceiver::setNtfyToken)); + + private NoticeReceiverMaskUtil() { + } + + /** + * Return a copy of the receiver with all secret fields masked. + * The given entity is not modified as it may still be attached to the persistence context. + * @param receiver receiver to mask, may be null + * @return masked copy, or null if receiver is null + */ + public static NoticeReceiver mask(NoticeReceiver receiver) { + if (receiver == null) { + return null; + } + NoticeReceiver masked = receiver.toBuilder().build(); + for (SecretField field : SECRET_FIELDS) { + field.setter().accept(masked, maskValue(field.getter().apply(masked))); + } + return masked; + } + + /** + * Replace every secret field of the incoming receiver that still holds the masked form + * of the stored secret with the stored value, so an edit that did not touch a secret keeps it. + * A re-entered secret or a cleared field is left untouched. + * @param incoming receiver submitted by the ui, modified in place + * @param existing receiver currently stored in the database + */ + public static void resolveMask(NoticeReceiver incoming, NoticeReceiver existing) { + if (incoming == null || existing == null) { + return; + } + for (SecretField field : SECRET_FIELDS) { + String submitted = field.getter().apply(incoming); + String stored = field.getter().apply(existing); + if (isMaskOf(submitted, stored)) { + field.setter().accept(incoming, stored); + } + } + } + + private static boolean isMaskOf(String submitted, String stored) { + if (submitted == null || StringUtils.isBlank(stored)) { + return false; + } + return submitted.equals(SECRET_MASK) || submitted.equals(maskValue(stored)); + } + + private static String maskValue(String value) { + if (StringUtils.isBlank(value)) { + return value; + } + if (value.length() < MIN_LENGTH_TO_SHOW_SUFFIX) { + return SECRET_MASK; + } + return SECRET_MASK + value.substring(value.length() - VISIBLE_SUFFIX_LENGTH); + } +} diff --git a/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/controller/NoticeConfigControllerTest.java b/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/controller/NoticeConfigControllerTest.java index 84ca807bcb..f82556c822 100644 --- a/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/controller/NoticeConfigControllerTest.java +++ b/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/controller/NoticeConfigControllerTest.java @@ -31,6 +31,7 @@ import java.util.Arrays; import java.util.List; import java.util.Optional; import org.apache.hertzbeat.alert.service.impl.NoticeConfigServiceImpl; +import org.apache.hertzbeat.alert.util.NoticeReceiverMaskUtil; import org.apache.hertzbeat.common.constants.CommonConstants; import org.apache.hertzbeat.common.entity.alerter.NoticeReceiver; import org.apache.hertzbeat.common.entity.alerter.NoticeRule; @@ -174,6 +175,7 @@ class NoticeConfigControllerTest { NoticeReceiver receiver1 = new NoticeReceiver(); receiver1.setId(1L); receiver1.setName("Receiver1"); + receiver1.setTgBotToken("1499012345:AAEOB_wEYS-DZyPM3h5NzI8voJM"); NoticeReceiver receiver2 = new NoticeReceiver(); receiver2.setId(2L); @@ -197,6 +199,7 @@ class NoticeConfigControllerTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.data.content[0].id").value(1)) .andExpect(jsonPath("$.data.content[0].name").value("Receiver1")) + .andExpect(jsonPath("$.data.content[0].tgBotToken").value(NoticeReceiverMaskUtil.SECRET_MASK + "voJM")) .andExpect(jsonPath("$.data.content[1].id").value(2)) .andExpect(jsonPath("$.data.content[1].name").value("Receiver2")) .andExpect(jsonPath("$.data.totalElements").value(2)) @@ -222,6 +225,8 @@ class NoticeConfigControllerTest { this.mockMvc.perform(MockMvcRequestBuilders.get("/api/notice/receiver/{id}", 7565463543L)) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE)) + .andExpect(jsonPath("$.data.accessToken").value(NoticeReceiverMaskUtil.SECRET_MASK + "739d")) + .andExpect(jsonPath("$.data.email").value("2762242004@qq.com")) .andReturn(); } diff --git a/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/service/NoticeConfigServiceTest.java b/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/service/NoticeConfigServiceTest.java index 4206140963..9b2780ba1e 100644 --- a/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/service/NoticeConfigServiceTest.java +++ b/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/service/NoticeConfigServiceTest.java @@ -22,6 +22,7 @@ import org.apache.hertzbeat.alert.dao.NoticeRuleDao; import org.apache.hertzbeat.alert.dao.NoticeTemplateDao; import org.apache.hertzbeat.alert.notice.AlertNoticeDispatch; import org.apache.hertzbeat.alert.service.impl.NoticeConfigServiceImpl; +import org.apache.hertzbeat.alert.util.NoticeReceiverMaskUtil; import org.apache.hertzbeat.common.cache.CacheFactory; import org.apache.hertzbeat.common.entity.alerter.GroupAlert; import org.apache.hertzbeat.common.entity.alerter.NoticeReceiver; @@ -48,6 +49,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -216,6 +218,39 @@ class NoticeConfigServiceTest { verify(noticeReceiverDao, times(1)).save(noticeReceiver); } + @Test + void editReceiverKeepsStoredSecretWhenMasked() { + final NoticeReceiver stored = new NoticeReceiver(); + stored.setId(5L); + stored.setTgBotToken("1499012345:AAEOB_wEYS-DZyPM3h5NzI8voJM"); + when(noticeReceiverDao.findById(5L)).thenReturn(Optional.of(stored)); + + final NoticeReceiver incoming = new NoticeReceiver(); + incoming.setId(5L); + incoming.setTgBotToken(NoticeReceiverMaskUtil.SECRET_MASK + "voJM"); + + noticeConfigService.editReceiver(incoming); + + assertEquals("1499012345:AAEOB_wEYS-DZyPM3h5NzI8voJM", incoming.getTgBotToken()); + verify(noticeReceiverDao, times(1)).save(incoming); + } + + @Test + void sendTestMsgResolvesMaskedSecret() { + final NoticeReceiver stored = new NoticeReceiver(); + stored.setId(5L); + stored.setTgBotToken("1499012345:AAEOB_wEYS-DZyPM3h5NzI8voJM"); + when(noticeReceiverDao.findById(5L)).thenReturn(Optional.of(stored)); + + final NoticeReceiver incoming = new NoticeReceiver(); + incoming.setId(5L); + incoming.setTgBotToken(NoticeReceiverMaskUtil.SECRET_MASK + "voJM"); + + noticeConfigService.sendTestMsg(incoming); + + assertEquals("1499012345:AAEOB_wEYS-DZyPM3h5NzI8voJM", incoming.getTgBotToken()); + } + @Test void deleteReceiver() { final Long receiverId = 23342525L; diff --git a/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/util/NoticeReceiverMaskUtilTest.java b/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/util/NoticeReceiverMaskUtilTest.java new file mode 100644 index 0000000000..7d42ed6e87 --- /dev/null +++ b/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/util/NoticeReceiverMaskUtilTest.java @@ -0,0 +1,134 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.apache.hertzbeat.alert.util; + +import org.apache.hertzbeat.common.entity.alerter.NoticeReceiver; +import org.junit.jupiter.api.Test; + +import static org.apache.hertzbeat.alert.util.NoticeReceiverMaskUtil.SECRET_MASK; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Test case for {@link NoticeReceiverMaskUtil} + */ +class NoticeReceiverMaskUtilTest { + + private NoticeReceiver buildReceiverWithSecrets() { + NoticeReceiver receiver = new NoticeReceiver(); + receiver.setId(1L); + receiver.setName("tom"); + receiver.setEmail("tom@usthe.com"); + receiver.setHookUrl("https://example.com/hook"); + receiver.setHookAuthToken("hook-auth-token-abcd"); + receiver.setAccessToken("c03a568a306f8fd84dab51ff03cf6af6ba676a3be940c904e1df2de34853739d"); + receiver.setTgBotToken("1499012345:AAEOB_wEYS-DZyPM3h5NzI8voJM"); + receiver.setSlackWebHookUrl("https://hooks.slack.com/services/X/Y/Zt0k3n"); + receiver.setAppSecret("oUydwn92ey0lnuY02MixNa57eNK-20dJn5NEOG-u2uE"); + receiver.setDiscordBotToken("MTA2NTMwMzU0ODY4Mzg4MjUzNw.discord.t0kn"); + receiver.setSmnAk("NCVBODJOEYHSW3VNSMAK"); + receiver.setSmnSk("nmSNhUJN9MlpPl8lfCsgdA0KvHCL9JSMSK"); + receiver.setServerChanToken("SCT193569TSNm6xIabdjqeZPtOGOWcvU1e"); + receiver.setGotifyToken("A845h__ZMqDxZlO"); + receiver.setNtfyToken("tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2"); + return receiver; + } + + @Test + void maskKeepsSuffixOfLongSecrets() { + NoticeReceiver receiver = buildReceiverWithSecrets(); + NoticeReceiver masked = NoticeReceiverMaskUtil.mask(receiver); + + assertEquals(SECRET_MASK + "abcd", masked.getHookAuthToken()); + assertEquals(SECRET_MASK + "739d", masked.getAccessToken()); + assertEquals(SECRET_MASK + "voJM", masked.getTgBotToken()); + assertEquals(SECRET_MASK + "0k3n", masked.getSlackWebHookUrl()); + assertEquals(SECRET_MASK + "u2uE", masked.getAppSecret()); + assertEquals(SECRET_MASK + "t0kn", masked.getDiscordBotToken()); + assertEquals(SECRET_MASK + "SMAK", masked.getSmnAk()); + assertEquals(SECRET_MASK + "SMSK", masked.getSmnSk()); + assertEquals(SECRET_MASK + "vU1e", masked.getServerChanToken()); + assertEquals(SECRET_MASK + "xZlO", masked.getGotifyToken()); + assertEquals(SECRET_MASK + "NIz2", masked.getNtfyToken()); + + assertEquals(receiver.getId(), masked.getId()); + assertEquals(receiver.getName(), masked.getName()); + assertEquals(receiver.getEmail(), masked.getEmail()); + assertEquals(receiver.getHookUrl(), masked.getHookUrl()); + } + + @Test + void maskHidesShortSecretsEntirely() { + NoticeReceiver receiver = new NoticeReceiver(); + receiver.setAccessToken("short-token"); + receiver.setGotifyToken("tiny"); + NoticeReceiver masked = NoticeReceiverMaskUtil.mask(receiver); + + assertEquals(SECRET_MASK, masked.getAccessToken()); + assertEquals(SECRET_MASK, masked.getGotifyToken()); + } + + @Test + void maskDoesNotModifyOriginalAndKeepsEmptySecrets() { + NoticeReceiver receiver = buildReceiverWithSecrets(); + NoticeReceiverMaskUtil.mask(receiver); + + assertEquals("1499012345:AAEOB_wEYS-DZyPM3h5NzI8voJM", receiver.getTgBotToken()); + assertEquals("nmSNhUJN9MlpPl8lfCsgdA0KvHCL9JSMSK", receiver.getSmnSk()); + + NoticeReceiver empty = new NoticeReceiver(); + NoticeReceiver maskedEmpty = NoticeReceiverMaskUtil.mask(empty); + assertNull(maskedEmpty.getAccessToken()); + assertNull(maskedEmpty.getNtfyToken()); + assertNull(NoticeReceiverMaskUtil.mask(null)); + } + + @Test + void resolveMaskRestoresOnlyMaskedFields() { + NoticeReceiver existing = buildReceiverWithSecrets(); + NoticeReceiver incoming = NoticeReceiverMaskUtil.mask(existing); + incoming.setAccessToken("new-access-token-1234"); + incoming.setGotifyToken(null); + incoming.setNtfyToken(SECRET_MASK); + + NoticeReceiverMaskUtil.resolveMask(incoming, existing); + + assertEquals("new-access-token-1234", incoming.getAccessToken()); + assertNull(incoming.getGotifyToken()); + assertEquals("tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", incoming.getNtfyToken()); + assertEquals("hook-auth-token-abcd", incoming.getHookAuthToken()); + assertEquals("1499012345:AAEOB_wEYS-DZyPM3h5NzI8voJM", incoming.getTgBotToken()); + assertEquals("https://hooks.slack.com/services/X/Y/Zt0k3n", incoming.getSlackWebHookUrl()); + assertEquals("oUydwn92ey0lnuY02MixNa57eNK-20dJn5NEOG-u2uE", incoming.getAppSecret()); + assertEquals("MTA2NTMwMzU0ODY4Mzg4MjUzNw.discord.t0kn", incoming.getDiscordBotToken()); + assertEquals("NCVBODJOEYHSW3VNSMAK", incoming.getSmnAk()); + assertEquals("nmSNhUJN9MlpPl8lfCsgdA0KvHCL9JSMSK", incoming.getSmnSk()); + assertEquals("SCT193569TSNm6xIabdjqeZPtOGOWcvU1e", incoming.getServerChanToken()); + } + + @Test + void resolveMaskIgnoresMaskWhenNothingIsStored() { + NoticeReceiver existing = new NoticeReceiver(); + NoticeReceiver incoming = new NoticeReceiver(); + incoming.setAccessToken(SECRET_MASK); + + NoticeReceiverMaskUtil.resolveMask(incoming, existing); + + assertEquals(SECRET_MASK, incoming.getAccessToken()); + } +} diff --git a/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/entity/alerter/NoticeReceiver.java b/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/entity/alerter/NoticeReceiver.java index cc7dcd6d6b..e31a1ac29c 100644 --- a/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/entity/alerter/NoticeReceiver.java +++ b/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/entity/alerter/NoticeReceiver.java @@ -48,7 +48,7 @@ import org.springframework.data.jpa.domain.support.AuditingEntityListener; @Entity @Table(name = "hzb_notice_receiver") @Data -@Builder +@Builder(toBuilder = true) @AllArgsConstructor @NoArgsConstructor @Schema(description = "Message notification recipient entity")