Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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,17 @@ private List<User> getAlarmNotificationUsers(SentAlarm alarm) {
.realmRoles(new StringPredicate(Constants.SUPER_USER_REALM_ROLE))
.serviceUsers(false);
users.addAll(Arrays.asList(identityProvider.queryUsers(userQuery)));
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 ->
Arrays.asList(identityProvider.getUserRealmRoles(alarm.getRealm(), user.getId()))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This introduces a synchronous Keycloak Admin API request for every potential notification recipient. These requests are serialized by KeycloakIdentityProvider.getRealms(), and a Keycloak failure can cause sendAlarm() to fail after the alarm has already been persisted.

Could recipient filtering avoid these synchronous external requests so that notification processing cannot cause an otherwise successful alarm operation to fail?

.contains(Constants.RESTRICTED_USER_REALM_ROLE)
&& !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))
.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;
}
}
34 changes: 34 additions & 0 deletions model/src/main/java/org/openremote/model/rules/Alarms.java
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,41 @@
/** Facade for sending {@link Alarm}s from rules RHS. */
@TsIgnore
public abstract class Alarms {
/**
* Creates an alarm for assets within the engine's scope. Realm- and asset-scoped engines use
* their own realm. Asset-scoped engines require at least one target, and every target must be the
* scoped asset itself or a descendant. Realm-scoped engines may omit targets.
*
* <p>Global engines use the supplied alarm realm, or infer it from the target assets when it is
* null or blank. All targets must belong to the resolved realm. The supplied alarm definition is
* preserved for global engines so it can be reused with targets in another realm.
*
* <p>An assignee is optional; when provided, the user must belong to the alarm's realm.
* Restricted assignees require at least one target and explicit links to every target asset. The
* caller must set the alarm's source ID to identify the ruleset.
*
* @return the created alarm ID, or {@code null} if the realm cannot be resolved, an asset-scoped
* engine has no targets, an asset is outside the engine's asset scope, or the assignee cannot
* access the targets
* @throws IllegalArgumentException if an asset ID is invalid or belongs to a different realm
*/
public abstract Long create(Alarm alarm, List<String> assetIds);

/**
* Adds asset links to an existing alarm. Realm- and asset-scoped engines require the alarm to
* belong to the engine's realm. Global engines use the existing alarm's realm. Every requested
* asset must belong to the alarm's realm.
*
* <p>Asset-scoped engines require at least one existing link, and all existing and requested
* assets must be within the engine's subtree. An assignee is optional; when provided, the user
* must belong to the alarm's realm. Restricted assignees must have explicit links to every
* existing and requested asset.
*
* <p>No links are changed if scope or assignee access is denied. Null or empty asset lists are
* ignored. Duplicate and existing links are accepted without changing their timestamps.
*
* @throws IllegalArgumentException if the alarm ID or an asset ID is invalid, or the alarm or a
* requested asset belongs to a different realm
*/
public abstract void linkAssets(List<String> assetIds, Long alarmId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,9 @@ class AlarmResourceTest extends Specification implements ManagerContainerTrait {
"Medium Unassigned Alarm" | "Another Description" | Severity.MEDIUM | null | 0
"Medium Assigned 1 Alarm" | "Assigned Description" | Severity.MEDIUM | keycloakTestSetup.testuser1Id | 0
"Medium Assigned 2 Alarm" | "Assigned Description" | Severity.MEDIUM | alarmsReadWriteUserId | 0
"High Unassigned Alarm" | "Critical Description" | Severity.HIGH | null | 1
// This alarm has no linked assets. The building realm's unrestricted writer has no email;
// its restricted writers must not receive automatic notifications for an unlinked alarm.
"High Unassigned Alarm" | "Critical Description" | Severity.HIGH | null | 0
"High Assigned 1 Alarm" | "Critical Description" | Severity.HIGH | keycloakTestSetup.testuser1Id | 0
"High Assigned 2 Alarm" | "Critical Description" | Severity.HIGH | alarmsReadWriteUserId | 1
}
Expand Down
Loading
Loading