diff --git a/openidm-core/src/main/java/org/forgerock/openidm/managed/ManagedObjectSet.java b/openidm-core/src/main/java/org/forgerock/openidm/managed/ManagedObjectSet.java index b5c28c1648..3f7300a815 100644 --- a/openidm-core/src/main/java/org/forgerock/openidm/managed/ManagedObjectSet.java +++ b/openidm-core/src/main/java/org/forgerock/openidm/managed/ManagedObjectSet.java @@ -12,6 +12,7 @@ * information: "Portions copyright [year] [name of copyright owner]". * * Copyright 2011-2016 ForgeRock AS. + * Portions copyright 2026 3A Systems, LLC. */ package org.forgerock.openidm.managed; @@ -149,6 +150,9 @@ private enum ScriptHook { /** Script to execute once an object is retrieved from the repository. */ onRetrieve, + /** Script to execute for each object returned from a query; return false to exclude the object. */ + onQueryResult, + /** Script to execute when an object is about to be stored in the repository. */ onStore, @@ -1286,6 +1290,20 @@ public boolean handleResource(ResourceResponse resource) { return false; } } + // Execute the onQueryResult script if configured; skip object if it returns false + try { + Object queryResultScriptResult = execScriptHook(managedContext, ScriptHook.onQueryResult, + resource.getContent(), + prepareScriptBindings(managedContext, request, resource.getId(), + new JsonValue(null), new JsonValue(null))); + if (Boolean.FALSE.equals(queryResultScriptResult)) { + // Object excluded by onQueryResult script + return true; + } + } catch (ResourceException e) { + ex[0] = e; + return false; + } if (ServerConstants.QUERY_ALL_IDS.equals(request.getQueryId())) { // Don't populate relationships if this is a query-all-ids query. resourceResponse = resource; diff --git a/openidm-core/src/test/java/org/forgerock/openidm/managed/ManagedObjectSetTest.java b/openidm-core/src/test/java/org/forgerock/openidm/managed/ManagedObjectSetTest.java index 2f9fde0e01..0a6d032e9a 100644 --- a/openidm-core/src/test/java/org/forgerock/openidm/managed/ManagedObjectSetTest.java +++ b/openidm-core/src/test/java/org/forgerock/openidm/managed/ManagedObjectSetTest.java @@ -12,6 +12,7 @@ * information: "Portions copyright [year] [name of copyright owner]". * * Copyright 2016 ForgeRock AS. + * Portions copyright 2026 3A Systems, LLC. */ package org.forgerock.openidm.managed; @@ -38,6 +39,7 @@ import java.security.KeyStore; import java.security.KeyStore.PasswordProtection; import java.security.KeyStore.SecretKeyEntry; +import java.util.ArrayList; import java.util.Collections; import java.util.LinkedList; import java.util.List; @@ -48,12 +50,15 @@ import com.fasterxml.jackson.databind.ObjectMapper; +import org.forgerock.json.JsonPointer; import org.forgerock.json.JsonValue; import org.forgerock.json.crypto.JsonDecryptFunction; import org.forgerock.json.crypto.simple.SimpleDecryptor; import org.forgerock.json.crypto.simple.SimpleKeySelector; import org.forgerock.json.crypto.simple.SimpleKeyStoreSelector; import org.forgerock.json.resource.MemoryBackend; +import org.forgerock.json.resource.QueryRequest; +import org.forgerock.json.resource.QueryResponse; import org.forgerock.json.resource.ResourceException; import org.forgerock.json.resource.ResourceResponse; import org.forgerock.json.resource.Router; @@ -78,6 +83,7 @@ import org.forgerock.services.context.RootContext; import org.forgerock.util.promise.Promise; import org.forgerock.util.promise.ResultHandler; +import org.forgerock.util.query.QueryFilter; import org.testng.annotations.Test; import org.testng.annotations.BeforeClass; @@ -100,6 +106,7 @@ public class ManagedObjectSetTest { private static final String CONF_MANAGED_USER_USING_ALIAS1 = "/conf/managed-user-alias1.json"; private static final String CONF_MANAGED_USER_USING_NO_ENCRYPTION = "/conf/managed-user-no-encryption.json"; private static final String CONF_MANAGED_USER_WITH_ACTION = "/conf/managed-user-action.json"; + private static final String CONF_MANAGED_USER_WITH_ON_QUERY_RESULT = "/conf/managed-user-on-query-result.json"; private static final String RESOURCE_ID = "user1"; private static final String KEYSTORE_PASSWORD = "Password1"; private static final int NUMBER_OF_USERS = 5; @@ -481,6 +488,14 @@ private ManagedObjectSet createManagedObjectSet(final String configJson, final C new NullActivityLogger()); } + private ManagedObjectSet createManagedObjectSetWithScriptRegistry(final String configJson, + final CryptoService cryptoService, final IDMConnectionFactory connectionFactory) throws Exception { + final AtomicReference routeService = new AtomicReference<>(mock(RouteService.class)); + final JsonValue config = getResource(configJson); + return new ManagedObjectSet(scriptRegistry, cryptoService, routeService, connectionFactory, config, + new NullActivityLogger()); + } + private JsonValue createUser(final String resourceId, final JsonValue userContent, final ManagedObjectSet managedObjectSet) throws ResourceException { Promise promise = managedObjectSet.createInstance(new RootContext(), @@ -537,6 +552,69 @@ List getErrors() { } } + /** + * Tests that when the {@code onQueryResult} script returns {@code false} for an object, + * that object is excluded from the query results. + */ + @Test + public void testQueryCollectionOnQueryResultExcludesObjects() throws Exception { + // given + final CryptoService cryptoService = createCryptoService(); + final ConnectionObjects connectionObjects = createConnectionObjects(); + final ManagedObjectSet managedObjectSet = + createManagedObjectSetWithScriptRegistry(CONF_MANAGED_USER_WITH_ON_QUERY_RESULT, cryptoService, + connectionObjects.getConnectionFactory()); + addRoutesToRouter(connectionObjects.getRouter(), managedObjectSet, new MemoryBackend()); + + // create 2 active users and 1 inactive user + createUser("activeUser1", createUserObject("activeUser1", true), managedObjectSet); + createUser("activeUser2", createUserObject("activeUser2", true), managedObjectSet); + createUser("inactiveUser", createUserObject("inactiveUser", false), managedObjectSet); + + // when: query all users + final List results = new ArrayList<>(); + final QueryRequest queryRequest = newQueryRequest(MANAGED_USER_RESOURCE_PATH) + .setQueryFilter(QueryFilter.alwaysTrue()); + managedObjectSet.queryCollection(new RootContext(), queryRequest, results::add) + .getOrThrowUninterruptibly(); + + // then: only active users are included; inactive user is excluded by onQueryResult + assertThat(results).hasSize(2); + assertThat(results.stream() + .map(r -> r.getContent().get(FIELD_ACTIVE).asBoolean()) + .allMatch(Boolean.TRUE::equals)).isTrue(); + } + + /** + * Tests that when no {@code onQueryResult} script is configured, all objects are returned + * from the query without any filtering. + */ + @Test + public void testQueryCollectionWithoutOnQueryResultIncludesAllObjects() throws Exception { + // given + final CryptoService cryptoService = createCryptoService(); + final ConnectionObjects connectionObjects = createConnectionObjects(); + final ManagedObjectSet managedObjectSet = + createManagedObjectSet(CONF_MANAGED_USER_USING_NO_ENCRYPTION, cryptoService, + connectionObjects.getConnectionFactory()); + addRoutesToRouter(connectionObjects.getRouter(), managedObjectSet, new MemoryBackend()); + + // create 3 users (mix of active/inactive) + createUser("user0", createUserObject("user0", "pwd0", "user0@example.com"), managedObjectSet); + createUser("user1", createUserObject("user1", "pwd1", "user1@example.com"), managedObjectSet); + createUser("user2", createUserObject("user2", "pwd2", "user2@example.com"), managedObjectSet); + + // when: query all users + final List results = new ArrayList<>(); + final QueryRequest queryRequest = newQueryRequest(MANAGED_USER_RESOURCE_PATH) + .setQueryFilter(QueryFilter.alwaysTrue()); + managedObjectSet.queryCollection(new RootContext(), queryRequest, results::add) + .getOrThrowUninterruptibly(); + + // then: all 3 users are returned (no onQueryResult hook to filter them) + assertThat(results).hasSize(3); + } + private static class ConnectionObjects { private IDMConnectionFactory connectionFactory; private Router router; diff --git a/openidm-core/src/test/resources/conf/managed-user-on-query-result.json b/openidm-core/src/test/resources/conf/managed-user-on-query-result.json new file mode 100644 index 0000000000..b847f33730 --- /dev/null +++ b/openidm-core/src/test/resources/conf/managed-user-on-query-result.json @@ -0,0 +1,17 @@ +{ + "name" : "user", + "onQueryResult" : { + "type" : "text/javascript", + "source" : "object.active !== false;" + }, + "schema" : { + "properties" : { + "_id" : { + "type" : "string" + }, + "active" : { + "type" : "boolean" + } + } + } +} diff --git a/openidm-doc/src/main/asciidoc/integrators-guide/appendix-objects.adoc b/openidm-doc/src/main/asciidoc/integrators-guide/appendix-objects.adoc index 14c757d4ac..a181bcf400 100644 --- a/openidm-doc/src/main/asciidoc/integrators-guide/appendix-objects.adoc +++ b/openidm-doc/src/main/asciidoc/integrators-guide/appendix-objects.adoc @@ -12,7 +12,7 @@ information: "Portions copyright [year] [name of copyright owner]". Copyright 2017 ForgeRock AS. - Portions Copyright 2024 3A Systems LLC. + Portions Copyright 2024-2026 3A Systems LLC. //// :figure-caption!: @@ -244,9 +244,10 @@ Specifies the configuration of each managed object. "onDelete" : script object, "postDelete": script object, "onValidate": script object, - "onRetrieve": script object, - "onStore" : script object, - "onSync" : script object + "onRetrieve" : script object, + "onQueryResult" : script object, + "onStore" : script object, + "onSync" : script object } ---- @@ -322,6 +323,12 @@ script object, optional + A script object to trigger when an object is retrieved from the repository. The object that was retrieved is provided in the root scope as an `object` property. The script can change the object. If an exception is thrown, then object retrieval fails. +onQueryResult:: +script object, optional + ++ +A script object to trigger for each object returned from a query. The object being evaluated is provided in the root scope as an `object` property. The script should return `true` (or a truthy value) to include the object in the query results, or `false` to exclude it. If an exception is thrown, the query fails. + onStore:: script object, optional diff --git a/openidm-doc/src/main/asciidoc/integrators-guide/appendix-scripting.adoc b/openidm-doc/src/main/asciidoc/integrators-guide/appendix-scripting.adoc index 473ee82a08..f64adea7cb 100644 --- a/openidm-doc/src/main/asciidoc/integrators-guide/appendix-scripting.adoc +++ b/openidm-doc/src/main/asciidoc/integrators-guide/appendix-scripting.adoc @@ -12,7 +12,7 @@ information: "Portions copyright [year] [name of copyright owner]". Copyright 2017 ForgeRock AS. - Portions Copyright 2024-2025 3A Systems LLC. + Portions Copyright 2024-2026 3A Systems LLC. //// :figure-caption!: @@ -1148,7 +1148,7 @@ condition, transform ==== Scripts called in the managed object configuration (`conf/managed.json`) file:: -onCreate, onRead, onUpdate, onDelete, onValidate, onRetrieve, onStore, onSync, postCreate, postUpdate, and postDelete +onCreate, onRead, onUpdate, onDelete, onValidate, onRetrieve, onQueryResult, onStore, onSync, postCreate, postUpdate, and postDelete + `managed.json` supports only one script per hook. If multiple scripts are defined for the same hook, only the last one is kept. @@ -1191,6 +1191,9 @@ a|object, oldObject, newObject a|onDelete, onRetrieve, onRead a|object +a|onQueryResult +a|object + a|postDelete a|oldObject