Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
ec6f481
Add failing tests for CUSTOM target rejection in device push notifica…
ebariaux Oct 21, 2026
5594b76
Reject CUSTOM targets in device push notifications before target expa…
ebariaux Oct 21, 2026
8b8ab1c
Add failing regression test for global rules notification submission
ebariaux Oct 21, 2026
9f6d5db
Fix global rules notification submission with an empty source ID
ebariaux Oct 21, 2026
da643c5
Add failing tests for restricted recipients in asset-scoped rule noti…
ebariaux Oct 21, 2026
6964bf8
Require scope links for restricted recipients of asset rule notificat…
ebariaux Oct 22, 2026
880cf89
Add failing tests for scoped topic push rejection and document breaki…
ebariaux Oct 22, 2026
b99b236
Reject topic and condition push notifications from realm- and asset-s…
ebariaux Oct 22, 2026
ed095fd
Add failing tests for missing user targets in rule notifications
ebariaux Oct 22, 2026
a3c466c
Reject missing user targets before processing scoped notifications
ebariaux Oct 22, 2026
053102c
Add tests for realm filtering of expanded push recipients
ebariaux Oct 22, 2026
509de2d
Filter expanded push recipients to the rule's realm
ebariaux Oct 22, 2026
1b39323
Add failing tests for realm filtering of expanded email recipients
ebariaux Oct 22, 2026
c30d39b
Filter expanded email recipients by realm and share scope resolution
ebariaux Oct 22, 2026
be81c42
Add failing tests for restricted users in asset notification expansion
ebariaux Oct 22, 2026
ddf7346
Filter restricted recipients during asset notification expansion
ebariaux Oct 22, 2026
b38d13f
Add failing tests for user realm filtering in asset push expansion
ebariaux Oct 22, 2026
38f7361
Filter asset-expanded push recipients by user realm
ebariaux Oct 22, 2026
120a147
Add failing tests for service and system accounts in asset push expan…
ebariaux Oct 22, 2026
6dc2203
Exclude service and system accounts from scoped asset push expansion
ebariaux Oct 22, 2026
dc1ebdb
Add scoped rule tests for localized push notifications
ebariaux Oct 22, 2026
154395e
Add localized notification recipient scoping tests
ebariaux Oct 22, 2026
ab77c91
Merge branch 'main' into bugfix/rules-notifications-facade-scoping
ebariaux Oct 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,8 @@ public List<Notification.Target> getTargets(
List<Notification.Target> mappedTargets = new ArrayList<>();

if (targets != null) {
String rulesRealm =
NotificationScope.resolveRulesRealm(source, sourceId, assetStorageService);

targets.forEach(
target -> {
Expand Down Expand Up @@ -267,6 +269,11 @@ public List<Notification.Target> getTargets(

// Filter users that don't have disabled email notifications attribute
if (userQuery != null) {
if (rulesRealm != null) {
// Stored asset links must not expand the audience beyond the rule's realm.
userQuery.realm(new RealmPredicate(rulesRealm));
}

// Exclude service accounts, system accounts and accounts with disabled email
// notifications
userQuery
Expand All @@ -284,6 +291,17 @@ true, new StringPredicate(User.SYSTEM_ACCOUNT_ATTRIBUTE)),
.filter(
user ->
!user.isSystemAccount() && !TextUtil.isNullOrEmpty(user.getEmail()))
// Filter derived recipients without removing the asset's own email target.
.filter(
user ->
source != Notification.Source.ASSET_RULESET
|| targetType != Notification.TargetType.ASSET
|| NotificationScope.isUserInAssetScope(
user.getRealm(),
user.getId(),
sourceId,
managerIdentityService.getIdentityProvider(),
assetStorageService))
.map(
user -> {
Notification.Target emailTarget =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
* Copyright 2026, OpenRemote Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package org.openremote.manager.notification;

import java.util.Arrays;
import java.util.List;
import org.openremote.manager.asset.AssetStorageService;
import org.openremote.manager.security.ManagerIdentityProvider;
import org.openremote.model.Constants;
import org.openremote.model.asset.Asset;
import org.openremote.model.notification.Notification;

final class NotificationScope {

private NotificationScope() {}

/**
* Restricted recipients must be directly linked to the scoped asset; unrestricted recipients need
* no asset link. The realm must be the recipient's realm. Callers are responsible for enforcing
* realm authorization.
*/
static boolean isUserInAssetScope(
String realm,
String userId,
String scopedAssetId,
ManagerIdentityProvider identityProvider,
AssetStorageService assetStorageService) {
boolean restrictedRecipient =
Arrays.asList(identityProvider.getUserRealmRoles(realm, userId))
.contains(Constants.RESTRICTED_USER_REALM_ROLE);
return !restrictedRecipient || assetStorageService.isUserAssets(userId, List.of(scopedAssetId));
}

/** Resolves the realm for realm- and asset-scoped rules, or {@code null} for other sources. */
static String resolveRulesRealm(
Notification.Source source, String sourceId, AssetStorageService assetStorageService) {
return switch (source) {
case REALM_RULESET -> sourceId;
case ASSET_RULESET -> {
Asset<?> scopedAsset = assetStorageService.find(sourceId, false);
if (scopedAsset == null) {
throw new NotificationProcessingException(
NotificationProcessingException.Reason.INSUFFICIENT_ACCESS,
"Rule's scoped asset does not exist");
}
yield scopedAsset.getRealm();
}
default -> null;
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -825,21 +825,24 @@
boolean realmMatch;

if (target.getType() == Notification.TargetType.USER) {
User[] targetUsers =
identityService
.getIdentityProvider()
.queryUsers(
// Exclude service accounts and system accounts
new UserQuery()
.ids(target.getId())
.serviceUsers(false)
.attributes(
new UserQuery.AttributeValuePredicate(
true, new StringPredicate(User.SYSTEM_ACCOUNT_ATTRIBUTE))));
if (targetUsers.length == 0) {
throw new NotificationProcessingException(
INSUFFICIENT_ACCESS,
"User targets must exist and must not be service or system accounts");
}
realmMatch =
Arrays.stream(
identityService
.getIdentityProvider()
.queryUsers(
// Exclude service accounts and system accounts
new UserQuery()
.ids(target.getId())
.serviceUsers(false)
.attributes(
new UserQuery.AttributeValuePredicate(
true,
new StringPredicate(
User.SYSTEM_ACCOUNT_ATTRIBUTE)))))
.allMatch(user -> realm.equals(user.getRealm()));
Arrays.stream(targetUsers).allMatch(user -> realm.equals(user.getRealm()));
} else {
// Can only send to the same realm as the requester realm
realmMatch = realm.equals(target.getId());
Expand All @@ -849,6 +852,18 @@
throw new NotificationProcessingException(
INSUFFICIENT_ACCESS, "Targets must all be in the same realm as the requester");
}
if (source == ASSET_RULESET && target.getType() == Notification.TargetType.USER) {
if (!NotificationScope.isUserInAssetScope(

Check warning on line 856 in manager/src/main/java/org/openremote/manager/notification/NotificationService.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Merge this if statement with the enclosing one.

See more on https://sonarcloud.io/project/issues?id=openremote_openremote&issues=AaFnv6OmRsnq_fNeFbA8&open=AaFnv6OmRsnq_fNeFbA8&pullRequest=3472
realm,
target.getId(),
assetId,
identityService.getIdentityProvider(),
assetStorageService)) {
throw new NotificationProcessingException(
INSUFFICIENT_ACCESS,
"Restricted user targets must be directly linked to the rule's scoped asset");
}
}
break;

case ASSET:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,11 +200,17 @@ public List<Notification.Target> getTargets(
List<Notification.Target> targets,
AbstractNotificationMessage message) {

// Check if message is going to a topic if so then filter consoles subscribed to that topic
PushNotificationMessage pushMessage = (PushNotificationMessage) message;
List<Notification.Target> mappedTargets = new ArrayList<>();

if (pushMessage.getTargetType() == TOPIC || pushMessage.getTargetType() == CONDITION) {
// Firebase resolves these audiences, so their realm and asset scope cannot be verified.
if (source == Notification.Source.REALM_RULESET
|| source == Notification.Source.ASSET_RULESET) {
throw new NotificationProcessingException(
NotificationProcessingException.Reason.INSUFFICIENT_ACCESS,
"Realm- and asset-scoped rules cannot send TOPIC or CONDITION push notifications");
}
mappedTargets.add(
new Notification.Target(
Notification.TargetType.CUSTOM,
Expand All @@ -213,6 +219,14 @@ public List<Notification.Target> getTargets(
}

if (targets != null) {
if (targets.stream().anyMatch(target -> target.getType() == Notification.TargetType.CUSTOM)) {
throw new NotificationProcessingException(
NotificationProcessingException.Reason.ERROR_TARGET_MAPPING,
"CUSTOM targets are not supported for device push notifications");
}

String rulesRealm =
NotificationScope.resolveRulesRealm(source, sourceId, assetStorageService);

// Filter out console targets
String[] assetTargets =
Expand Down Expand Up @@ -275,13 +289,51 @@ public List<Notification.Target> getTargets(
case USER ->
// Any console assets linked to the target user
assetQuery.userIds(targetId);
case ASSET ->
case ASSET -> {
if (rulesRealm == null) {
// Any users linked to this asset and then their linked console assets
assetQuery.userIds(
assetStorageService.findUserAssetLinks(null, null, targetId).stream()
.map(ual -> ual.getId().getUserId())
.collect(Collectors.toSet())
.distinct()
.toArray(String[]::new));
} else {
// Resolve regular, non-system users in the rule's realm before expanding their
// consoles.
assetQuery.userIds(
Arrays.stream(
managerIdentityService
.getIdentityProvider()
.queryUsers(
new UserQuery()
.assets(targetId)
.realm(new RealmPredicate(rulesRealm))
.serviceUsers(false)
.attributes(
new UserQuery.AttributeValuePredicate(
true,
new StringPredicate(
User.SYSTEM_ACCOUNT_ATTRIBUTE)))))
.filter(
user ->
source != Notification.Source.ASSET_RULESET
|| NotificationScope.isUserInAssetScope(
user.getRealm(),
user.getId(),
sourceId,
managerIdentityService.getIdentityProvider(),
assetStorageService))
.map(User::getId)
.distinct()
.toArray(String[]::new));
}
}
}

if (rulesRealm != null) {
// Delivery consoles must share the rule's realm, but need not be in its asset
// subtree.
assetQuery.realm(new RealmPredicate(rulesRealm));
}

List<String> consoleAssetIds =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ public NotificationsFacade(

public void send(Notification notification) {
Notification.Source source;
String sourceId = null;
String sourceId = "";

if (rulesEngineId.getScope() == GlobalRuleset.class) {
source = GLOBAL_RULESET;
Expand Down
Loading
Loading