Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
35e1dfc
Add test validating AlarmFacade rule scoping, currently failing
ebariaux Oct 20, 2026
c8f2b65
Enforce scope and assignee access when rules create alarms
ebariaux Oct 20, 2026
ebe1878
Add regression tests for alarm facade asset linking scope, not passin…
ebariaux Oct 20, 2026
8e6a8b7
Enforce scope and assignee access when linking alarm assets
ebariaux Oct 20, 2026
d21e259
Fix recipient scoping for unassigned high-severity alarms
ebariaux Oct 20, 2026
8a4a0bb
Add failing tests for asset-scoped alarms without target assets
ebariaux Oct 20, 2026
083b1a0
Require target assets when creating asset-scoped alarms
ebariaux Oct 20, 2026
1f27b0a
Add failing tests for global-rule alarm creation
ebariaux Oct 20, 2026
4664f3b
Fix realm resolution and validation for global-rule alarm creation
ebariaux Oct 20, 2026
c90e643
Add failing tests for global-rule alarm asset linking
ebariaux Oct 20, 2026
44c0ac4
Fix realm resolution for global-rule alarm asset linking
ebariaux Oct 20, 2026
d963d6e
Merge branch 'main' into bugfix/rules-alarm-facade-scoping
ebariaux Oct 20, 2026
962a450
Merge branch 'main' into bugfix/rules-alarm-facade-scoping
ebariaux Oct 21, 2026
a584b81
Merge branch 'main' into bugfix/rules-alarm-facade-scoping
ebariaux Oct 22, 2026
60d2371
Merge branch 'main' into bugfix/rules-alarm-facade-scoping
ebariaux Oct 28, 2026
d846d9d
Resolve alarm notification recipients before persisting changes
ebariaux Oct 29, 2026
40c853c
Merge branch 'main' into bugfix/rules-alarm-facade-scoping
ebariaux Oct 30, 2026
92f6502
Revert "Resolve alarm notification recipients before persisting changes"
ebariaux Oct 30, 2026
fe81673
Batch restricted alarm recipient lookup with UserQuery
ebariaux Oct 30, 2026
69c817f
Merge branch 'main' into bugfix/rules-alarm-facade-scoping
ebariaux Oct 30, 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 @@ -169,9 +169,10 @@ protected void validateAssetIds(Collection<String> assetIds) {

/**
* Throws an {@link EntityNotFoundException} if any asset does not exist, or an {@link
* AlarmAssetLinkRealmMismatchException} if any asset does not belong to {@code realm}.
* AlarmAssetLinkRealmMismatchException} if any asset does not belong to {@code realm}. Callers
* are responsible for enforcing realm authorization.
*/
protected void validateAssetsInRealm(String realm, Collection<String> assetIds) {
public void validateAssetsInRealm(String realm, Collection<String> assetIds) {
validateAssetIds(assetIds);
Set<String> uniqueAssetIds = new HashSet<>(assetIds);
Map<String, String> assetRealms = assetStorageService.getAssetRealms(uniqueAssetIds);
Expand Down Expand Up @@ -235,11 +236,12 @@ public SentAlarm sendAlarm(Alarm alarm, List<String> assetIds) {

/**
* Sends e-mail and push notifications for an alarm to the alarm assignee. If an assignee is not
* set all users having the {@link Constants#READ_ADMIN_ROLE} or {@link
* Constants#WRITE_ALARMS_ROLE} are notified.
* set, users in the alarm's realm having the {@link Constants#SUPER_USER_REALM_ROLE} or {@link
* Constants#WRITE_ALARMS_ROLE} are notified. Restricted users must have explicit links to every
* linked asset; they are not notified for alarms without linked assets.
*
* @param alarm the alarm to send e-mail and push notifications for
* @param excludeUserIds users matching these user IDs will are excluded from the notifications
* @param excludeUserIds users matching these user IDs are excluded from the notifications
*/
protected void sendAssigneeNotification(SentAlarm alarm, Set<String> excludeUserIds) {
List<User> users = getAlarmNotificationUsers(alarm);
Expand Down Expand Up @@ -298,6 +300,29 @@ private List<User> getAlarmNotificationUsers(SentAlarm alarm) {
.realmRoles(new StringPredicate(Constants.SUPER_USER_REALM_ROLE))
.serviceUsers(false);
users.addAll(Arrays.asList(identityProvider.queryUsers(userQuery)));
if (users.isEmpty()) {
return users;
}
// UserQuery matches directly assigned roles; composite roles are not expanded.
UserQuery restrictedUsersQuery =
new UserQuery()
.realm(new RealmPredicate(alarm.getRealm()))
.ids(users.stream().map(User::getId).toArray(String[]::new))
.realmRoles(new StringPredicate(Constants.RESTRICTED_USER_REALM_ROLE));
Set<String> restrictedUserIds =
Arrays.stream(identityProvider.queryUsers(restrictedUsersQuery))
.map(User::getId)
.collect(Collectors.toSet());
List<String> assetIds =
getAssetLinks(alarm.getId(), alarm.getRealm()).stream()
.map(link -> link.getId().getAssetId())
.distinct()
.toList();
// Both notification channels include all linked assets, so partial access is insufficient.
users.removeIf(
user ->
restrictedUserIds.contains(user.getId())
&& !assetStorageService.isUserAssets(user.getId(), assetIds));
} else {
users.add(identityProvider.getUser(alarm.getAssigneeId()));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ public RulesEngine(
new UsersFacade<>(id, assetStorageService, notificationService, identityService);
this.notificationFacade = new NotificationsFacade<>(id, notificationService);
this.webhooksFacade = new WebhooksFacade<>(id, webhookService);
this.alarmsFacade = new AlarmFacade<>(id, alarmService);
this.alarmsFacade = new AlarmFacade<>(id, alarmService, assetStorageService, identityService);
this.historicFacade = new HistoricFacade<>(id, assetStorageService, assetDatapointService);
this.predictedFacade =
new PredictedFacade<>(id, assetStorageService, assetPredictedDatapointService);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,49 +18,191 @@
*/
package org.openremote.manager.rules.facade;

import static org.openremote.model.Constants.RESTRICTED_USER_REALM_ROLE;

import jakarta.persistence.EntityNotFoundException;
import java.util.Arrays;
import java.util.List;
import java.util.logging.Logger;
import java.util.stream.Stream;
import org.openremote.manager.alarm.AlarmService;
import org.openremote.manager.asset.AssetStorageService;
import org.openremote.manager.rules.RulesEngineId;
import org.openremote.manager.security.ManagerIdentityService;
import org.openremote.model.alarm.Alarm;
import org.openremote.model.alarm.Alarm.Source;
import org.openremote.model.alarm.SentAlarm;
import org.openremote.model.rules.Alarms;
import org.openremote.model.rules.AssetRuleset;
import org.openremote.model.rules.GlobalRuleset;
import org.openremote.model.rules.RealmRuleset;
import org.openremote.model.rules.Ruleset;
import org.openremote.model.security.User;
import org.openremote.model.util.TextUtil;
import org.openremote.model.util.ValueUtil;

public class AlarmFacade<T extends Ruleset> extends Alarms {
private static final Logger LOG = Logger.getLogger(AlarmFacade.class.getName());

protected final RulesEngineId<T> rulesEngineId;
protected final AlarmService alarmService;
protected final AssetStorageService assetStorageService;
protected final ManagerIdentityService identityService;

public AlarmFacade(RulesEngineId<T> rulesEngineId, AlarmService alarmService) {
public AlarmFacade(
RulesEngineId<T> rulesEngineId,
AlarmService alarmService,
AssetStorageService assetStorageService,
ManagerIdentityService identityService) {
this.rulesEngineId = rulesEngineId;
this.alarmService = alarmService;
this.assetStorageService = assetStorageService;
this.identityService = identityService;
}

@Override
public Long create(Alarm alarm, List<String> assetIds) {
List<String> targetIds = assetIds == null ? List.of() : assetIds.stream().distinct().toList();

if (rulesEngineId.getScope() == AssetRuleset.class && targetIds.isEmpty()) {
LOG.warning("Cannot create an asset-scoped alarm without target assets: " + rulesEngineId);
return null;
}

String realm = resolveCreationRealm(alarm, targetIds);
if (realm == null) {
LOG.warning(
"Cannot create a global alarm without a realm or target assets: " + rulesEngineId);
return null;
}

if (!targetIds.isEmpty()) {
// Preserve the existing validation errors for missing and foreign-realm assets.
alarmService.validateAssetsInRealm(realm, targetIds);
}
if (!areAssetsInScope(targetIds)
|| !isAssigneeAllowed(realm, alarm.getAssigneeId(), targetIds)) {
return null;
}

Alarm.Source source;

if (rulesEngineId.getScope() == GlobalRuleset.class) {
// JSON actions can reuse this definition with targets in another realm on the next execution.
alarm = ValueUtil.clone(alarm);
source = Source.GLOBAL_RULESET;
} else if (rulesEngineId.getScope() == RealmRuleset.class) {
source = Source.REALM_RULESET;
} else {
source = Source.ASSET_RULESET;
}

alarm.setRealm(rulesEngineId.getRealm().orElseThrow());
alarm.setRealm(realm);
alarm.setSource(source);

return alarmService.sendAlarm(alarm, assetIds).getId();
return alarmService.sendAlarm(alarm, targetIds).getId();
}

@Override
public void linkAssets(List<String> assetIds, Long alarmId) {
if (assetIds != null && !assetIds.isEmpty()) {
String realmId =
rulesEngineId
.getRealm()
.orElseThrow(() -> new IllegalStateException("Realm ruleset must have a realm ID"));
alarmService.linkAssets(assetIds, realmId, alarmId);
if (assetIds == null || assetIds.isEmpty()) {
return;
}

SentAlarm alarm = alarmService.getAlarm(alarmId);
String realm =
rulesEngineId.getScope() == GlobalRuleset.class
? alarm.getRealm()
: rulesEngineId
.getRealm()
.orElseThrow(() -> new IllegalStateException("Realm ruleset must have a realm ID"));
if (!realm.equals(alarm.getRealm())) {
throw new AlarmService.AlarmAssetLinkRealmMismatchException(
"Alarm '" + alarmId + "' is not in realm '" + realm + "'");
}

List<String> targetIds = assetIds.stream().distinct().toList();
alarmService.validateAssetsInRealm(realm, targetIds);
List<String> existingAssetIds =
alarmService.getAssetLinks(alarmId, realm).stream()
.map(link -> link.getId().getAssetId())
.toList();
if (rulesEngineId.getScope() == AssetRuleset.class && existingAssetIds.isEmpty()) {
LOG.warning(
"Cannot establish asset scope for unlinked alarm: " + alarmId + ", " + rulesEngineId);
return;
}

List<String> allAssetIds =
Stream.concat(existingAssetIds.stream(), targetIds.stream()).distinct().toList();
if (!areAssetsInScope(allAssetIds)
|| !isAssigneeAllowed(realm, alarm.getAssigneeId(), allAssetIds)) {
return;
}

alarmService.linkAssets(targetIds, realm, alarmId);
}

private String resolveCreationRealm(Alarm alarm, List<String> targetIds) {
if (rulesEngineId.getScope() != GlobalRuleset.class) {
return rulesEngineId.getRealm().orElseThrow();
}
if (!TextUtil.isNullOrEmpty(alarm.getRealm())) {
return alarm.getRealm();
}
if (targetIds.isEmpty()) {
return null;
}

String firstAssetId = targetIds.getFirst();
if (TextUtil.isNullOrEmpty(firstAssetId)) {
throw new IllegalArgumentException("Missing asset ID");
}
// Creation validates the complete target list against this realm before persisting anything.
String realm = assetStorageService.getAssetRealms(List.of(firstAssetId)).get(firstAssetId);
if (realm == null) {
throw new EntityNotFoundException("Asset '" + firstAssetId + "' does not exist");
}
return realm;
}

private boolean areAssetsInScope(List<String> assetIds) {
if (rulesEngineId.getScope() == AssetRuleset.class) {
for (String assetId : assetIds) {
if (!FacadeHelper.doesRuleEngineScopeAllowAccess(
rulesEngineId, assetId, assetStorageService)) {
LOG.warning("Alarm asset is outside engine scope: " + assetId + ", " + rulesEngineId);
return false;
}
}
}
return true;
}

private boolean isAssigneeAllowed(String realm, String assigneeId, List<String> assetIds) {
if (assigneeId == null) {
return true;
}
User assignee = identityService.getIdentityProvider().getUser(assigneeId);
if (assignee == null || !realm.equals(assignee.getRealm())) {
LOG.warning(
"Alarm assignee is missing or belongs to another realm: "
+ assigneeId
+ ", "
+ rulesEngineId);
return false;
}
boolean restricted =
Arrays.asList(identityService.getIdentityProvider().getUserRealmRoles(realm, assigneeId))
Comment thread
wborn marked this conversation as resolved.
Resolved
.contains(RESTRICTED_USER_REALM_ROLE);
if (restricted && !assetStorageService.isUserAssets(assigneeId, assetIds)) {
LOG.warning(
"Restricted alarm assignee lacks links to all target assets: "
+ assigneeId
+ ", "
+ rulesEngineId);
return false;
}
return true;
}
}
Loading
Loading