From 998b2e4f7903fe8011257b48216acd073bfe8ca7 Mon Sep 17 00:00:00 2001
From: Eric Bariaux <375613+ebariaux@users.noreply.github.com>
Date: Fri, 23 Oct 2026 10:34:09 +0200
Subject: [PATCH 01/13] Add failing tests for realm filtering in asset-scoped
user queries
---
.../test/rules/UsersFacadeTest.groovy | 111 ++++++++++++++++++
1 file changed, 111 insertions(+)
create mode 100644 test/src/test/groovy/org/openremote/test/rules/UsersFacadeTest.groovy
diff --git a/test/src/test/groovy/org/openremote/test/rules/UsersFacadeTest.groovy b/test/src/test/groovy/org/openremote/test/rules/UsersFacadeTest.groovy
new file mode 100644
index 0000000000..8a12dfaa8c
--- /dev/null
+++ b/test/src/test/groovy/org/openremote/test/rules/UsersFacadeTest.groovy
@@ -0,0 +1,111 @@
+/*
+ * 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 .
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+package org.openremote.test.rules
+
+import org.openremote.manager.asset.AssetStorageService
+import org.openremote.manager.notification.NotificationService
+import org.openremote.manager.rules.RulesEngineId
+import org.openremote.manager.rules.facade.UsersFacade
+import org.openremote.manager.security.ManagerIdentityService
+import org.openremote.manager.setup.SetupService
+import org.openremote.model.asset.UserAssetLink
+import org.openremote.model.asset.impl.ThingAsset
+import org.openremote.model.query.UserQuery
+import org.openremote.model.security.User
+import org.openremote.setup.integration.KeycloakTestSetup
+import org.openremote.test.ManagerContainerTrait
+import spock.lang.Specification
+
+class UsersFacadeTest extends Specification implements ManagerContainerTrait {
+
+ def "Asset scope querying users linked to #targetName with #linkedUserNames returns #expectedUserNames"() {
+ given: "real identity and asset storage services"
+ def container = startContainer(defaultConfig(), defaultServices())
+ def assetStorageService = container.getService(AssetStorageService.class)
+ def identityService = container.getService(ManagerIdentityService.class)
+ def identityProvider = identityService.getIdentityProvider()
+ def keycloakSetup = container.getService(SetupService.class).getTaskOfType(KeycloakTestSetup.class)
+ def realm = keycloakSetup.realmBuilding.name
+ def foreignRealm = keycloakSetup.realmCity.name
+ List createdAssetIds = []
+ List createdUsers = []
+
+ and: "a scope root, its descendant and an unrelated asset in the same realm"
+ def createAsset = { String name, String parentId ->
+ def asset = assetStorageService.merge(new ThingAsset("Users facade ${name}")
+ .setRealm(realm).setParentId(parentId))
+ createdAssetIds.add(asset.id)
+ asset
+ }
+ def scopedAsset = createAsset("scope", null)
+ def childAsset = createAsset("child", scopedAsset.id)
+ def unrelatedAsset = createAsset("unrelated", null)
+ def targetAsset = targetName == "root" ? scopedAsset : childAsset
+
+ and: "regular users in the rule's realm and another realm"
+ def users = ["sameRealm", "foreignRealm", "outsideSubtree"].collectEntries { name ->
+ def userRealm = name == "foreignRealm" ? foreignRealm : realm
+ def user = identityProvider.createUpdateUser(userRealm,
+ new User().setUsername("users-facade-${UUID.randomUUID()}").setEnabled(true), null, false)
+ createdUsers.add(user)
+ def storedUser = identityProvider.getUser(user.id)
+ assert storedUser.realm == userRealm
+ assert !storedUser.isServiceAccount()
+ assert !storedUser.isSystemAccount()
+ [(name): storedUser]
+ }
+
+ and: "stored links include any foreign user selected by this case"
+ // Storage permits cross-realm links independently of REST link-creation checks.
+ linkedUserNames.each { name ->
+ def user = users[name]
+ assetStorageService.storeUserAssetLinks([new UserAssetLink(user.realm, user.id, targetAsset.id)])
+ }
+ def outsideUser = users.outsideSubtree
+ assetStorageService.storeUserAssetLinks([new UserAssetLink(outsideUser.realm, outsideUser.id, unrelatedAsset.id)])
+ assert identityProvider.queryUsers(new UserQuery().assets(targetAsset.id))*.id.toSet() ==
+ linkedUserNames.collect { users[it].id }.toSet()
+ assert identityProvider.queryUsers(new UserQuery().assets(unrelatedAsset.id))*.id == [outsideUser.id]
+
+ def facade = new UsersFacade(new RulesEngineId(realm, scopedAsset.id), assetStorageService,
+ container.getService(NotificationService.class), identityService)
+ def query = new UserQuery().ids(users.values().collect { it.id } as String[])
+
+ when: "the asset-scoped facade queries all three users"
+ def result = facade.getResults(query).toList()
+
+ then: "only users in the rule's realm linked within its subtree are returned"
+ result.toSet() == expectedUserNames.collect { users[it].id }.toSet()
+
+ cleanup:
+ if (!createdAssetIds.isEmpty()) {
+ assert assetStorageService.delete(createdAssetIds)
+ }
+ createdUsers.each { identityProvider.deleteUser(it.realm, it.id) }
+
+ where:
+ targetName | linkedUserNames | expectedUserNames
+ "root" | ["sameRealm"] | ["sameRealm"]
+ "root" | ["foreignRealm"] | []
+ "root" | ["sameRealm", "foreignRealm"] | ["sameRealm"]
+ "child" | ["sameRealm"] | ["sameRealm"]
+ "child" | ["foreignRealm"] | []
+ "child" | ["sameRealm", "foreignRealm"] | ["sameRealm"]
+ }
+}
From b89bf7a862be67241f6dd14c572a386e861787eb Mon Sep 17 00:00:00 2001
From: Eric Bariaux <375613+ebariaux@users.noreply.github.com>
Date: Fri, 23 Oct 2026 10:40:32 +0200
Subject: [PATCH 02/13] Enforce realm filtering in asset-scoped user queries
---
.../org/openremote/manager/rules/facade/UsersFacade.java | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/manager/src/main/java/org/openremote/manager/rules/facade/UsersFacade.java b/manager/src/main/java/org/openremote/manager/rules/facade/UsersFacade.java
index a5b217cbca..0582872e64 100644
--- a/manager/src/main/java/org/openremote/manager/rules/facade/UsersFacade.java
+++ b/manager/src/main/java/org/openremote/manager/rules/facade/UsersFacade.java
@@ -71,7 +71,13 @@ public Stream getResults(UserQuery userQuery) {
.orElseThrow(
() -> new IllegalArgumentException("Realm ID missing: " + rulesEngineId)));
} else if (AssetRuleset.class.isAssignableFrom(rulesEngineId.getScope())) {
- userQuery.realmPredicate = null;
+ // Asset links must not expose users from outside the rule's realm.
+ userQuery.realmPredicate =
+ new RealmPredicate(
+ rulesEngineId
+ .getRealm()
+ .orElseThrow(
+ () -> new IllegalArgumentException("Realm ID missing: " + rulesEngineId)));
String assetId =
rulesEngineId
.getAssetId()
From d6b72e4021c5e7b2920128c39e797dcd6385bd90 Mon Sep 17 00:00:00 2001
From: Eric Bariaux <375613+ebariaux@users.noreply.github.com>
Date: Fri, 23 Oct 2026 10:48:32 +0200
Subject: [PATCH 03/13] Add failing tests for service-account exclusion with
username filters
---
.../test/rules/UsersFacadeTest.groovy | 73 +++++++++++++++++++
1 file changed, 73 insertions(+)
diff --git a/test/src/test/groovy/org/openremote/test/rules/UsersFacadeTest.groovy b/test/src/test/groovy/org/openremote/test/rules/UsersFacadeTest.groovy
index 8a12dfaa8c..fa4b4ccacb 100644
--- a/test/src/test/groovy/org/openremote/test/rules/UsersFacadeTest.groovy
+++ b/test/src/test/groovy/org/openremote/test/rules/UsersFacadeTest.groovy
@@ -27,6 +27,7 @@ import org.openremote.manager.setup.SetupService
import org.openremote.model.asset.UserAssetLink
import org.openremote.model.asset.impl.ThingAsset
import org.openremote.model.query.UserQuery
+import org.openremote.model.query.filter.StringPredicate
import org.openremote.model.security.User
import org.openremote.setup.integration.KeycloakTestSetup
import org.openremote.test.ManagerContainerTrait
@@ -108,4 +109,76 @@ class UsersFacadeTest extends Specification implements ManagerContainerTrait {
"child" | ["foreignRealm"] | []
"child" | ["sameRealm", "foreignRealm"] | ["sameRealm"]
}
+
+ def "#scope scope querying usernames #queryUserNames returns only regular users #expectedUserNames"() {
+ given: "real identity and asset storage services"
+ def container = startContainer(defaultConfig(), defaultServices())
+ def assetStorageService = container.getService(AssetStorageService.class)
+ def identityService = container.getService(ManagerIdentityService.class)
+ def identityProvider = identityService.getIdentityProvider()
+ def keycloakSetup = container.getService(SetupService.class).getTaskOfType(KeycloakTestSetup.class)
+ def realm = keycloakSetup.realmBuilding.name
+ List createdUsers = []
+ def scopedAsset = assetStorageService.merge(new ThingAsset("Users facade username filtering").setRealm(realm))
+
+ and: "two regular users and a real service account, all within the rule's scope"
+ def users = ["regular", "unmatched", "service"].collectEntries { name ->
+ def user = identityProvider.createUpdateUser(realm,
+ new User().setUsername("users-facade-${UUID.randomUUID()}").setEnabled(true)
+ .setServiceAccount(name == "service"), null, false)
+ createdUsers.add(user)
+ def storedUser = identityProvider.getUser(user.id)
+ assert storedUser.realm == realm
+ assert storedUser.isServiceAccount() == (name == "service")
+ assert !storedUser.isSystemAccount()
+ assetStorageService.storeUserAssetLinks([new UserAssetLink(realm, user.id, scopedAsset.id)])
+ [(name): storedUser]
+ }
+ assert identityProvider.queryUsers(new UserQuery().assets(scopedAsset.id))*.id.toSet() ==
+ users.values().collect { it.id }.toSet()
+
+ and: "a username query which independently matches the intended accounts"
+ def createQuery = {
+ def query = new UserQuery().ids(users.values().collect { it.id } as String[])
+ if (!queryUserNames.isEmpty()) {
+ query.usernames(queryUserNames.collect { name ->
+ def user = users[name]
+ // User.getUsername() omits Keycloak's prefix; username predicates match the stored name.
+ def storedUsername = user.isServiceAccount() ? User.SERVICE_ACCOUNT_PREFIX + user.username : user.username
+ new StringPredicate(storedUsername)
+ } as StringPredicate[])
+ }
+ query
+ }
+ def matchingNames = queryUserNames.isEmpty() ? users.keySet() : queryUserNames
+ assert identityProvider.queryUsers(createQuery())*.id.toSet() ==
+ matchingNames.collect { users[it].id }.toSet()
+
+ def engineId = scope == "global" ? new RulesEngineId() :
+ scope == "realm" ? new RulesEngineId(realm) : new RulesEngineId(realm, scopedAsset.id)
+ def facade = new UsersFacade(engineId, assetStorageService,
+ container.getService(NotificationService.class), identityService)
+
+ when: "the facade applies its account exclusions to the query"
+ def result = facade.getResults(createQuery()).toList()
+
+ then: "service accounts stay excluded and the username filter is respected"
+ result.toSet() == expectedUserNames.collect { users[it].id }.toSet()
+
+ cleanup:
+ if (scopedAsset != null) {
+ assert assetStorageService.delete([scopedAsset.id])
+ }
+ createdUsers.each { identityProvider.deleteUser(it.realm, it.id) }
+
+ where:
+ [scope, queryUserNames, expectedUserNames] << ["global", "realm", "asset"].collectMany { scope ->
+ [
+ [[], ["regular", "unmatched"]],
+ [["regular"], ["regular"]],
+ [["service"], []],
+ [["regular", "service"], ["regular"]]
+ ].collect { row -> [scope] + row }
+ }
+ }
}
From 3a4386de10006ae4d6a0380051c77edf9368d6a2 Mon Sep 17 00:00:00 2001
From: Eric Bariaux <375613+ebariaux@users.noreply.github.com>
Date: Fri, 23 Oct 2026 11:00:23 +0200
Subject: [PATCH 04/13] Combine service-account filtering with username
predicates using AND
---
.../security/ManagerIdentityProvider.java | 25 +++++++------------
1 file changed, 9 insertions(+), 16 deletions(-)
diff --git a/manager/src/main/java/org/openremote/manager/security/ManagerIdentityProvider.java b/manager/src/main/java/org/openremote/manager/security/ManagerIdentityProvider.java
index 6cfa228fd7..49705385b0 100644
--- a/manager/src/main/java/org/openremote/manager/security/ManagerIdentityProvider.java
+++ b/manager/src/main/java/org/openremote/manager/security/ManagerIdentityProvider.java
@@ -113,22 +113,6 @@ static User[] getUsersFromDb(PersistenceService persistenceService, UserQuery qu
List