diff --git a/openam-core/src/main/resources/amUpgrade.properties b/openam-core/src/main/resources/amUpgrade.properties index b116bbf060..151c9397b4 100644 --- a/openam-core/src/main/resources/amUpgrade.properties +++ b/openam-core/src/main/resources/amUpgrade.properties @@ -23,6 +23,7 @@ # # Portions Copyrighted 2014-2016 Nomura Research Institute, Ltd +# Portions Copyright 2026 3A Systems, LLC. report=OpenAM Upgrade Report%LF%=====================%LF%Created: %CREATED_DATE%%LF%%LF%Existing Version: %EXISTING_VERSION%%LF%New Version: %NEW_VERSION%%LF%%LF% upgrade.servicereport=Services Upgrade Report%LF%-----------------------%LF%%LF%New Services%LF%%LF%%NEW_SERVICES%%LF%%LF%Modified Services%LF%%LF%%MODIFIED_SERVICES%%LF%%LF%New Schemas%LF%%LF%%NEW_SCHEMAS%%LF%%LF%New Sub Schemas%LF%%LF%%NEW_SUB_SCHEMAS%%LF%%LF%Deleted Services%LF%%LF%%DELETED_SERVICES%%LF%%LF% @@ -164,6 +165,11 @@ upgrade.scripting.global.settings=Scripting Global Settings modified ({0}) upgrade.scripting.global.context=Settings for {0} moved to {1} upgrade.scripting.global.engine.start=Upgrading Global Engine Configuration for script context: {0} upgrade.scripting.global.script.start=Upgrading default script to global script: {0} +# Scripting service sub-configurations upgrade +upgrade.scripting.subconfigs.report=Scripting Service Configuration Report%LF%--------------------------------------%LF%%REPORT_DATA%%LF% +upgrade.scripting.subconfigs=New Scripting Service configurations ({0}) +upgrade.scripting.subconfigs.new=New Scripting Service configuration: {0} +upgrade.scripting.subconfigs.new.start=Adding Scripting Service configuration: {0} # Update Post Authentication Plugins upgrade upgrade.postauthenticationplugins.short=Post Authentication Plugin classes to upgrade diff --git a/openam-server-only/src/main/webapp/WEB-INF/template/sms/serverupgrade.properties b/openam-server-only/src/main/webapp/WEB-INF/template/sms/serverupgrade.properties index d2b288525b..99845f2adf 100644 --- a/openam-server-only/src/main/webapp/WEB-INF/template/sms/serverupgrade.properties +++ b/openam-server-only/src/main/webapp/WEB-INF/template/sms/serverupgrade.properties @@ -21,6 +21,8 @@ # your own identifying information: # "Portions Copyrighted [year] [name of copyright owner]" # +# Portions Copyright 2026 3A Systems, LLC. +# defaults.to.upgrade=com.iplanet.am.version,com.iplanet.am.console.deploymentDescriptor,com.iplanet.am.daemons,\ com.iplanet.am.buildVersion,com.iplanet.am.buildRevision,com.iplanet.am.buildDate @@ -36,6 +38,7 @@ upgrade.helper=iPlanetAMLoggingService=org.forgerock.openam.upgrade.helpers.Logg sunAMAuthOAuthService=org.forgerock.openam.upgrade.helpers.OAuth2AuthModuleUpgradeHelper,\ iPlanetAMAuthScriptedService=org.forgerock.openam.upgrade.helpers.ScriptedAuthHelper,\ iPlanetAMAuthDeviceIdMatchService=org.forgerock.openam.upgrade.helpers.ScriptedAuthHelper,\ + ScriptingService=org.forgerock.openam.upgrade.helpers.ScriptingServiceHelper,\ AuditService=org.forgerock.openam.upgrade.helpers.AuditUpgradeHelper,\ iPlanetAMAuthOpenIdConnectService=org.forgerock.openam.upgrade.helpers.OpenIdConnectAuthModuleServiceHelper,\ sunFAMSAML2Configuration=org.forgerock.openam.upgrade.helpers.SAML2ConfigHelper,\ @@ -43,6 +46,4 @@ upgrade.helper=iPlanetAMLoggingService=org.forgerock.openam.upgrade.helpers.Logg MailServer=org.forgerock.openam.upgrade.helpers.MailServiceUpgradeHelper,\ iPlanetAMAuthAuthenticatorOATHService=org.forgerock.openam.upgrade.helpers.AuthAuthenticatorOathHelper services.to.delete=iPlanetAMAuthSafeWordService,iPlanetAMAuthUnixService,sunFAMLibertyInteractionService,sunFAMLibertySecurityService - RestSecurity=org.forgerock.openam.upgrade.helpers.UserSelfServiceHelper -services.to.delete=iPlanetAMAuthSafeWordService,iPlanetAMAuthUnixService,sunFAMLibertyInteractionService,sunFAMLibertySecurityService diff --git a/openam-upgrade/src/main/java/org/forgerock/openam/upgrade/helpers/ScriptingServiceHelper.java b/openam-upgrade/src/main/java/org/forgerock/openam/upgrade/helpers/ScriptingServiceHelper.java new file mode 100644 index 0000000000..5ce42cf5ae --- /dev/null +++ b/openam-upgrade/src/main/java/org/forgerock/openam/upgrade/helpers/ScriptingServiceHelper.java @@ -0,0 +1,84 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2026 3A Systems, LLC. + */ +package org.forgerock.openam.upgrade.helpers; + +import java.util.HashSet; +import java.util.Iterator; +import java.util.Set; + +import org.forgerock.openam.upgrade.UpgradeException; +import org.w3c.dom.Node; + +import com.sun.identity.shared.xml.XMLUtils; +import com.sun.identity.sm.AbstractUpgradeHelper; +import com.sun.identity.sm.AttributeSchemaImpl; + +/** + * This upgrade helper keeps the script context choice values declared by the Scripting Service schema — the + * global {@code defaultScriptContext} attribute and the {@code context} attribute of the realm-level + * {@code scriptConfiguration} sub-schema — in sync with the service definition, so that script contexts + * introduced in later versions become available on upgraded instances. The administrator's configured default + * is preserved as long as it is still a valid choice. + */ +public class ScriptingServiceHelper extends AbstractUpgradeHelper { + + private static final String DEFAULT_SCRIPT_CONTEXT = "defaultScriptContext"; + private static final String SCRIPT_CONTEXT = "context"; + private static final String CHOICE_VALUES = "ChoiceValues"; + private static final String CHOICE_VALUE = "ChoiceValue"; + + /** + * Default constructor + */ + public ScriptingServiceHelper() { + attributes.add(DEFAULT_SCRIPT_CONTEXT); + attributes.add(SCRIPT_CONTEXT); + } + + @Override + public AttributeSchemaImpl upgradeAttribute(AttributeSchemaImpl attributeToUpgrade, + AttributeSchemaImpl attributeFromNewSchema) throws UpgradeException { + + Set newChoiceValues = getRawChoiceValues(attributeFromNewSchema); + if (getRawChoiceValues(attributeToUpgrade).equals(newChoiceValues)) { + return null; + } + // For Global attributes the administrator's configured value is persisted as the schema default, so + // carry it over instead of reverting to the default bundled in the service definition — unless that + // value is no longer a valid choice. + Set existingDefaults = attributeToUpgrade.getDefaultValues(); + if (existingDefaults.isEmpty() || !newChoiceValues.containsAll(existingDefaults)) { + return attributeFromNewSchema; + } + return updateDefaultValues(attributeFromNewSchema, existingDefaults); + } + + /** + * Read the choice values from the raw attribute schema node. {@link AttributeSchemaImpl} only parses + * {@code } for choice-typed attributes, while the service definition also declares them on + * the {@code type="single"} attribute {@code scriptConfiguration.context}. + */ + private static Set getRawChoiceValues(AttributeSchemaImpl attribute) { + Set values = new HashSet<>(); + Node choiceValuesNode = XMLUtils.getChildNode(attribute.getAttributeSchemaNode(), CHOICE_VALUES); + if (choiceValuesNode != null) { + for (Iterator it = XMLUtils.getChildNodes(choiceValuesNode, CHOICE_VALUE).iterator(); it.hasNext();) { + values.add(XMLUtils.getValueOfValueNode((Node) it.next())); + } + } + return values; + } +} diff --git a/openam-upgrade/src/main/java/org/forgerock/openam/upgrade/steps/UpgradeServiceUtils.java b/openam-upgrade/src/main/java/org/forgerock/openam/upgrade/steps/UpgradeServiceUtils.java index 0906f9c00d..bca94e0396 100644 --- a/openam-upgrade/src/main/java/org/forgerock/openam/upgrade/steps/UpgradeServiceUtils.java +++ b/openam-upgrade/src/main/java/org/forgerock/openam/upgrade/steps/UpgradeServiceUtils.java @@ -12,6 +12,7 @@ * information: "Portions copyright [year] [name of copyright owner]". * * Copyright 2015 ForgeRock AS. + * Portions Copyright 2026 3A Systems, LLC. */ package org.forgerock.openam.upgrade.steps; @@ -59,7 +60,7 @@ private UpgradeServiceUtils() {} * @return A map of service name to the service DOM models. * @throws UpgradeException When the service XML cannot be loaded. */ - static Map getServiceDefinitions(SSOToken token) throws UpgradeException { + public static Map getServiceDefinitions(SSOToken token) throws UpgradeException { List serviceNames = new ArrayList<>(); serviceNames.addAll(UpgradeUtils.getPropertyValues(SetupConstants.PROPERTY_FILENAME, SetupConstants.SERVICE_NAMES)); diff --git a/openam-upgrade/src/main/java/org/forgerock/openam/upgrade/steps/scripting/UpgradeScriptingSubConfigsStep.java b/openam-upgrade/src/main/java/org/forgerock/openam/upgrade/steps/scripting/UpgradeScriptingSubConfigsStep.java new file mode 100644 index 0000000000..c9f1673cbc --- /dev/null +++ b/openam-upgrade/src/main/java/org/forgerock/openam/upgrade/steps/scripting/UpgradeScriptingSubConfigsStep.java @@ -0,0 +1,306 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2026 3A Systems, LLC. + */ +package org.forgerock.openam.upgrade.steps.scripting; + +import static org.forgerock.openam.upgrade.UpgradeServices.LF; +import static org.forgerock.openam.upgrade.UpgradeServices.tagSwapReport; + +import java.security.PrivilegedAction; +import java.text.MessageFormat; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import jakarta.inject.Inject; + +import org.forgerock.openam.sm.datalayer.api.ConnectionFactory; +import org.forgerock.openam.sm.datalayer.api.ConnectionType; +import org.forgerock.openam.sm.datalayer.api.DataLayer; +import org.forgerock.openam.upgrade.UpgradeException; +import org.forgerock.openam.upgrade.UpgradeProgress; +import org.forgerock.openam.upgrade.UpgradeStepInfo; +import org.forgerock.openam.upgrade.steps.AbstractUpgradeStep; +import org.forgerock.openam.upgrade.steps.UpgradeServiceUtils; +import org.forgerock.openam.utils.StringUtils; +import org.w3c.dom.Document; +import org.w3c.dom.Node; + +import com.iplanet.sso.SSOException; +import com.iplanet.sso.SSOToken; +import com.sun.identity.shared.xml.XMLUtils; +import com.sun.identity.sm.CreateServiceConfig; +import com.sun.identity.sm.SMSException; +import com.sun.identity.sm.SMSUtils; +import com.sun.identity.sm.ServiceConfig; +import com.sun.identity.sm.ServiceConfigManager; +import com.sun.identity.sm.ServiceNotFoundException; + +/** + * This upgrade step ensures that any global sub-configurations defined in scripting.xml (script contexts with + * their engine configurations, and the default global scripts) that are missing from the configuration data store + * are added to it. New script contexts are only registered by the SMS when the whole service is new, so instances + * upgraded from a version that already contained the Scripting Service would otherwise be left without the + * configuration for contexts introduced in later versions (see OPENAM issue #1103). + *

+ * The step only creates missing sub-configurations; attributes of existing sub-configurations are never + * reconciled with the service definition, so changes to e.g. an existing engine configuration whitelist do not + * reach upgraded instances. It also cannot create a missing single-instance sub-configuration (such as the whole + * {@code globalScripts} node) under a parent that already has other children, because the SMS rejects such an + * add; all such nodes have existed since OpenAM 13, so this is not reachable on supported upgrade paths. + */ +@UpgradeStepInfo(dependsOn = "org.forgerock.openam.upgrade.steps.UpgradeServiceSchemaStep") +public class UpgradeScriptingSubConfigsStep extends AbstractUpgradeStep { + + private static final String SCRIPTING_SERVICE_NAME = "ScriptingService"; + private static final String GLOBAL_SCRIPTS = "globalScripts"; + private static final String NAME = "name"; + private static final String ID = "id"; + private static final String PRIORITY = "priority"; + private static final String AUDIT_REPORT = "upgrade.scripting.subconfigs.report"; + private static final String AUDIT_NEW_SUB_CONFIGS = "upgrade.scripting.subconfigs"; + private static final String AUDIT_NEW_SUB_CONFIG = "upgrade.scripting.subconfigs.new"; + private static final String AUDIT_NEW_SUB_CONFIG_START = "upgrade.scripting.subconfigs.new.start"; + + private final List missingSubConfigs = new ArrayList<>(); + /** + * Display names of every sub-configuration that will be created, including descendants of the missing nodes. + * Only used for reporting; {@link #perform()} creates descendants by recursing into the missing nodes. + */ + private final List reportedSubConfigs = new ArrayList<>(); + + @Inject + public UpgradeScriptingSubConfigsStep(PrivilegedAction adminTokenAction, + @DataLayer(ConnectionType.DATA_LAYER) ConnectionFactory connectionFactory) { + super(adminTokenAction, connectionFactory); + } + + @Override + public void initialize() throws UpgradeException { + try { + ServiceConfig globalConfig = getScriptingGlobalConfig(); + if (globalConfig == null) { + DEBUG.message("No global configuration found for {}. Nothing to upgrade", SCRIPTING_SERVICE_NAME); + return; + } + Node globalConfigNode = getGlobalConfigurationNode(getScriptingServiceXML()); + if (globalConfigNode == null) { + DEBUG.message("No global configuration defined in the {} service definition", SCRIPTING_SERVICE_NAME); + return; + } + captureMissingSubConfigs(globalConfigNode, globalConfig, new ArrayList()); + moveGlobalScriptsFirst(); + } catch (ServiceNotFoundException e) { + DEBUG.message("Scripting service not found. Nothing to upgrade", e); + } catch (UpgradeException e) { + throw e; + } catch (Exception e) { + DEBUG.error("An error occurred while looking for missing Scripting Service configurations", e); + throw new UpgradeException("Unable to detect missing Scripting Service configurations", e); + } + } + + /** + * Create missing entries under {@code globalScripts} before the script contexts that reference them via + * {@code defaultScript}, so that a failure part-way through {@link #perform()} cannot leave a context + * pointing at a script that does not exist yet. + */ + private void moveGlobalScriptsFirst() { + List globalScripts = new ArrayList<>(missingSubConfigs.size()); + List others = new ArrayList<>(missingSubConfigs.size()); + for (MissingSubConfig missing : missingSubConfigs) { + String root = missing.parentPath.isEmpty() ? missing.name : missing.parentPath.get(0); + (GLOBAL_SCRIPTS.equals(root) ? globalScripts : others).add(missing); + } + missingSubConfigs.clear(); + missingSubConfigs.addAll(globalScripts); + missingSubConfigs.addAll(others); + } + + private Node getGlobalConfigurationNode(Document scriptingDocument) { + for (Iterator it = XMLUtils.getChildNodes(scriptingDocument.getDocumentElement(), SMSUtils.SERVICE).iterator(); + it.hasNext();) { + Node serviceNode = (Node) it.next(); + if (SCRIPTING_SERVICE_NAME.equals(XMLUtils.getNodeAttributeValue(serviceNode, NAME))) { + Node configurationNode = XMLUtils.getChildNode(serviceNode, SMSUtils.CONFIGURATION); + return configurationNode == null ? null : XMLUtils.getChildNode(configurationNode, + SMSUtils.GLOBAL_CONFIG); + } + } + return null; + } + + private void captureMissingSubConfigs(Node parentNode, ServiceConfig parentConfig, List parentPath) + throws SMSException, SSOException { + for (Iterator it = XMLUtils.getChildNodes(parentNode, SMSUtils.SUB_CONFIG).iterator(); it.hasNext();) { + Node node = (Node) it.next(); + String name = XMLUtils.getNodeAttributeValue(node, NAME); + // For an absent entry getSubConfig may return a non-null config wrapping a non-existent SMSEntry + // when the sub-config name matches a sub-schema name (e.g. engineConfiguration, globalScripts), + // so presence must be determined via ServiceConfig#exists. + ServiceConfig existingConfig = parentConfig.getSubConfig(name); + if (!SMSUtils.serviceExists(existingConfig)) { + MissingSubConfig missing = new MissingSubConfig(new ArrayList<>(parentPath), name, node); + missingSubConfigs.add(missing); + reportSubConfigTree(missing.getDisplayName(), node); + } else { + parentPath.add(name); + captureMissingSubConfigs(node, existingConfig, parentPath); + parentPath.remove(parentPath.size() - 1); + } + } + } + + private void reportSubConfigTree(String displayName, Node node) { + reportedSubConfigs.add(displayName); + for (Iterator it = XMLUtils.getChildNodes(node, SMSUtils.SUB_CONFIG).iterator(); it.hasNext();) { + Node child = (Node) it.next(); + reportSubConfigTree(displayName + '/' + XMLUtils.getNodeAttributeValue(child, NAME), child); + } + } + + @Override + public boolean isApplicable() { + return !missingSubConfigs.isEmpty(); + } + + @Override + public void perform() throws UpgradeException { + try { + ServiceConfig globalConfig = getScriptingGlobalConfig(); + for (MissingSubConfig missing : missingSubConfigs) { + UpgradeProgress.reportStart(AUDIT_NEW_SUB_CONFIG_START, missing.name); + ServiceConfig parentConfig = globalConfig; + for (String parentName : missing.parentPath) { + parentConfig = parentConfig.getSubConfig(parentName); + if (parentConfig == null) { + throw new UpgradeException("Missing parent configuration for " + missing.getDisplayName()); + } + } + // The missing list was captured in initialize(); the entry may have been created since. + if (SMSUtils.serviceExists(parentConfig.getSubConfig(missing.name))) { + DEBUG.message("Scripting Service configuration {} already exists, skipping", missing.name); + } else { + addSubConfig(parentConfig, missing.node); + } + UpgradeProgress.reportEnd("upgrade.success"); + } + } catch (UpgradeException e) { + UpgradeProgress.reportEnd("upgrade.failed"); + throw e; + } catch (Exception e) { + UpgradeProgress.reportEnd("upgrade.failed"); + DEBUG.error("An error occurred while adding missing Scripting Service configurations", e); + throw new UpgradeException("Unable to add missing Scripting Service configurations", e); + } + } + + private void addSubConfig(ServiceConfig parentConfig, Node node) throws SMSException, SSOException { + String name = XMLUtils.getNodeAttributeValue(node, NAME); + String id = XMLUtils.getNodeAttributeValue(node, ID); + if (StringUtils.isEmpty(id)) { + id = name; + } + String priority = XMLUtils.getNodeAttributeValue(node, PRIORITY); + Map> attributes = CreateServiceConfig.getAttributeValuePairs(node); + parentConfig.addSubConfig(name, id, priority == null ? 0 : Integer.parseInt(priority), + attributes == null ? Collections.>emptyMap() : attributes); + DEBUG.message("Created Scripting Service configuration {} with id {}", name, id); + + Iterator children = XMLUtils.getChildNodes(node, SMSUtils.SUB_CONFIG).iterator(); + if (children.hasNext()) { + ServiceConfig createdConfig = parentConfig.getSubConfig(name); + if (createdConfig == null) { + throw new SMSException("Unable to read newly created Scripting Service configuration " + name); + } + while (children.hasNext()) { + addSubConfig(createdConfig, (Node) children.next()); + } + } + } + + @Override + public String getShortReport(String delimiter) { + StringBuilder sb = new StringBuilder(); + if (!reportedSubConfigs.isEmpty()) { + sb.append(MessageFormat.format(BUNDLE.getString(AUDIT_NEW_SUB_CONFIGS), reportedSubConfigs.size())); + sb.append(delimiter); + } + return sb.toString(); + } + + @Override + public String getDetailedReport(String delimiter) { + Map tags = new HashMap<>(); + tags.put(LF, delimiter); + + StringBuilder sb = new StringBuilder(); + for (String displayName : reportedSubConfigs) { + sb.append(INDENT); + sb.append(MessageFormat.format(BUNDLE.getString(AUDIT_NEW_SUB_CONFIG), displayName)); + sb.append(delimiter); + } + tags.put("%REPORT_DATA%", sb.toString()); + return tagSwapReport(tags, AUDIT_REPORT); + } + + /** + * Get the Scripting Service definition XML with all tags swapped for their configured values. + * @return The Scripting Service DOM model. + * @throws UpgradeException When the service XML cannot be loaded. + */ + protected Document getScriptingServiceXML() throws UpgradeException { + Document document = UpgradeServiceUtils.getServiceDefinitions(getAdminToken()).get(SCRIPTING_SERVICE_NAME); + if (document == null) { + throw new UpgradeException("Unable to find the Scripting Service definition"); + } + return document; + } + + /** + * Get the global configuration of the Scripting Service from the configuration data store. + * @return The global Scripting Service configuration. + * @throws SMSException When the Scripting Service is not available. + * @throws SSOException When the admin token is not valid. + */ + protected ServiceConfig getScriptingGlobalConfig() throws SMSException, SSOException { + return new ServiceConfigManager(SCRIPTING_SERVICE_NAME, getAdminToken()).getGlobalConfig(null); + } + + private static final class MissingSubConfig { + + private final List parentPath; + private final String name; + private final Node node; + + private MissingSubConfig(List parentPath, String name, Node node) { + this.parentPath = parentPath; + this.name = name; + this.node = node; + } + + private String getDisplayName() { + StringBuilder sb = new StringBuilder(); + for (String parentName : parentPath) { + sb.append(parentName).append('/'); + } + return sb.append(name).toString(); + } + } +} diff --git a/openam-upgrade/src/test/java/org/forgerock/openam/upgrade/helpers/ScriptingServiceHelperTest.java b/openam-upgrade/src/test/java/org/forgerock/openam/upgrade/helpers/ScriptingServiceHelperTest.java new file mode 100644 index 0000000000..93b3574caa --- /dev/null +++ b/openam-upgrade/src/test/java/org/forgerock/openam/upgrade/helpers/ScriptingServiceHelperTest.java @@ -0,0 +1,123 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2026 3A Systems, LLC. + */ +package org.forgerock.openam.upgrade.helpers; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.testng.annotations.Test; +import org.w3c.dom.Node; + +import com.sun.identity.shared.xml.XMLUtils; +import com.sun.identity.sm.AttributeSchemaImpl; + +/** + * Unit test to exercise the behaviour of {@link ScriptingServiceHelper}. + */ +public class ScriptingServiceHelperTest { + + private static final String OLD_CHOICE_VALUES = + "" + + "POLICY_CONDITION" + + "AUTHENTICATION_SERVER_SIDE" + + "AUTHENTICATION_CLIENT_SIDE" + + "OIDC_CLAIMS" + + ""; + private static final String NEW_CHOICE_VALUES = OLD_CHOICE_VALUES.replace("", + "OAUTH2_ACCESS_TOKEN_MODIFICATION" + + ""); + + private final ScriptingServiceHelper helper = new ScriptingServiceHelper(); + + @Test + public void addsNewChoiceValuesPreservingConfiguredDefault() throws Exception { + AttributeSchemaImpl upgraded = helper.upgradeAttribute( + globalAttributeSchema(OLD_CHOICE_VALUES, "OIDC_CLAIMS"), + globalAttributeSchema(NEW_CHOICE_VALUES, "POLICY_CONDITION")); + + assertThat(upgraded).isNotNull(); + assertThat(upgraded.getChoiceValues()).contains("OAUTH2_ACCESS_TOKEN_MODIFICATION"); + assertThat(upgraded.getDefaultValues()).containsExactly("OIDC_CLAIMS"); + } + + @Test + public void returnsNullWhenChoiceValuesAreUnchanged() throws Exception { + assertThat(helper.upgradeAttribute( + globalAttributeSchema(NEW_CHOICE_VALUES, "OIDC_CLAIMS"), + globalAttributeSchema(NEW_CHOICE_VALUES, "POLICY_CONDITION"))).isNull(); + } + + @Test + public void usesBundledDefaultWhenNoDefaultIsConfigured() throws Exception { + AttributeSchemaImpl upgraded = helper.upgradeAttribute( + globalAttributeSchema(OLD_CHOICE_VALUES, null), + globalAttributeSchema(NEW_CHOICE_VALUES, "POLICY_CONDITION")); + + assertThat(upgraded).isNotNull(); + assertThat(upgraded.getChoiceValues()).contains("OAUTH2_ACCESS_TOKEN_MODIFICATION"); + assertThat(upgraded.getDefaultValues()).containsExactly("POLICY_CONDITION"); + } + + @Test + public void revertsToBundledDefaultWhenConfiguredDefaultIsNoLongerAValidChoice() throws Exception { + String oldChoiceValues = OLD_CHOICE_VALUES.replace("", + "REMOVED_CONTEXT"); + AttributeSchemaImpl newAttribute = globalAttributeSchema(NEW_CHOICE_VALUES, "POLICY_CONDITION"); + + AttributeSchemaImpl upgraded = helper.upgradeAttribute( + globalAttributeSchema(oldChoiceValues, "REMOVED_CONTEXT"), newAttribute); + + assertThat(upgraded).isSameAs(newAttribute); + assertThat(upgraded.getDefaultValues()).containsExactly("POLICY_CONDITION"); + } + + @Test + public void syncsChoiceValuesOfSingleTypedRealmContextAttribute() throws Exception { + // scriptConfiguration.context is type="single", for which AttributeSchemaImpl does not parse + // , so the helper must compare the raw schema nodes to detect the change. + AttributeSchemaImpl oldAttribute = contextAttributeSchema(OLD_CHOICE_VALUES); + AttributeSchemaImpl newAttribute = contextAttributeSchema(NEW_CHOICE_VALUES); + assertThat(oldAttribute.getChoiceValues()).isNull(); + assertThat(newAttribute.getChoiceValues()).isNull(); + + assertThat(helper.upgradeAttribute(oldAttribute, newAttribute)).isSameAs(newAttribute); + assertThat(helper.upgradeAttribute(newAttribute, contextAttributeSchema(NEW_CHOICE_VALUES))).isNull(); + } + + private static AttributeSchemaImpl globalAttributeSchema(String choiceValues, String defaultValue) { + return parse("" + + choiceValues + + (defaultValue == null ? "" : "" + defaultValue + "") + + ""); + } + + private static AttributeSchemaImpl contextAttributeSchema(String choiceValues) { + return parse("" + + choiceValues + ""); + } + + private static AttributeSchemaImpl parse(String xml) { + Node node = XMLUtils.toDOMDocument(xml, null).getDocumentElement(); + return new TestAttributeSchemaImpl(node); + } + + /** Grants access to the protected {@link AttributeSchemaImpl} constructor. */ + private static final class TestAttributeSchemaImpl extends AttributeSchemaImpl { + + TestAttributeSchemaImpl(Node node) { + super(node); + } + } +} diff --git a/openam-upgrade/src/test/java/org/forgerock/openam/upgrade/steps/scripting/UpgradeScriptingSubConfigsStepTest.java b/openam-upgrade/src/test/java/org/forgerock/openam/upgrade/steps/scripting/UpgradeScriptingSubConfigsStepTest.java new file mode 100644 index 0000000000..2474413c66 --- /dev/null +++ b/openam-upgrade/src/test/java/org/forgerock/openam/upgrade/steps/scripting/UpgradeScriptingSubConfigsStepTest.java @@ -0,0 +1,286 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2026 3A Systems, LLC. + */ +package org.forgerock.openam.upgrade.steps.scripting; + +import static java.util.Collections.singleton; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.security.PrivilegedAction; +import java.util.Map; +import java.util.Set; + +import org.forgerock.openam.sm.datalayer.api.ConnectionFactory; +import org.forgerock.openam.upgrade.UpgradeException; +import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; +import org.w3c.dom.Document; + +import com.iplanet.sso.SSOToken; +import com.sun.identity.shared.xml.XMLUtils; +import com.sun.identity.sm.ServiceConfig; + +/** + * Unit test to exercise the behaviour of {@link UpgradeScriptingSubConfigsStep}. + */ +public class UpgradeScriptingSubConfigsStepTest { + + private static final String EXISTING_CONTEXT = "OIDC_CLAIMS"; + private static final String EXISTING_SCRIPT_ID = "36863ffb-40ec-48b9-94b1-9a99f71cc3b5"; + private static final String NEW_CONTEXT = "OAUTH2_ACCESS_TOKEN_MODIFICATION"; + private static final String NEW_SCRIPT_ID = "d22f9a0c-426a-4466-b95e-d0f125b0d5fa"; + private static final String GLOBAL_SCRIPTS = "globalScripts"; + private static final String ENGINE_CONFIGURATION = "engineConfiguration"; + private static final String SYNTHETIC_EMPTY_CONFIG = "syntheticEmptyConfig"; + + private UpgradeScriptingSubConfigsStep upgradeStep; + + private PrivilegedAction adminTokenAction; + private ConnectionFactory connectionFactory; + + private ServiceConfig globalConfig; + private ServiceConfig existingContextConfig; + private ServiceConfig existingEngineConfig; + private ServiceConfig globalScriptsConfig; + private ServiceConfig existingScriptConfig; + private ServiceConfig createdContextConfig; + + @BeforeMethod + public void setUp() throws Exception { + globalConfig = mock(ServiceConfig.class); + existingContextConfig = mock(ServiceConfig.class); + existingEngineConfig = mock(ServiceConfig.class); + globalScriptsConfig = mock(ServiceConfig.class); + existingScriptConfig = mock(ServiceConfig.class); + createdContextConfig = mock(ServiceConfig.class); + + when(globalConfig.getSubConfig(EXISTING_CONTEXT)).thenReturn(existingContextConfig); + when(existingContextConfig.getSubConfig(ENGINE_CONFIGURATION)).thenReturn(existingEngineConfig); + when(globalConfig.getSubConfig(GLOBAL_SCRIPTS)).thenReturn(globalScriptsConfig); + when(globalScriptsConfig.getSubConfig(EXISTING_SCRIPT_ID)).thenReturn(existingScriptConfig); + when(existingContextConfig.exists()).thenReturn(true); + when(existingEngineConfig.exists()).thenReturn(true); + when(globalScriptsConfig.exists()).thenReturn(true); + when(existingScriptConfig.exists()).thenReturn(true); + + adminTokenAction = mock(PrivilegedAction.class); + connectionFactory = mock(ConnectionFactory.class); + upgradeStep = new SafeUpgradeScriptingSubConfigsStep(adminTokenAction, connectionFactory); + } + + @Test + public void addsMissingContextAndGlobalScript() throws Exception { + // The new script context and its default global script do not exist yet; the context is only + // resolvable after it has been created. exists() is stubbed to false explicitly because the + // perform-time re-check must still see the entry as absent. + when(globalConfig.getSubConfig(NEW_CONTEXT)).thenReturn(null, createdContextConfig); + when(createdContextConfig.exists()).thenReturn(false); + + upgradeStep.initialize(); + + assertThat(upgradeStep.isApplicable()).isTrue(); + assertThat(upgradeStep.getShortReport("-")).isEqualTo("New Scripting Service configurations (4)-"); + assertThat(upgradeStep.getDetailedReport("-")) + .contains(NEW_CONTEXT) + .contains(NEW_CONTEXT + "/" + ENGINE_CONFIGURATION) + .contains(NEW_CONTEXT + "/" + SYNTHETIC_EMPTY_CONFIG) + .contains(GLOBAL_SCRIPTS + "/" + NEW_SCRIPT_ID); + + upgradeStep.perform(); + + // The global script is created before the context whose defaultScript references it. + InOrder creationOrder = inOrder(globalScriptsConfig, globalConfig); + ArgumentCaptor>> scriptAttributes = ArgumentCaptor.forClass(Map.class); + creationOrder.verify(globalScriptsConfig).addSubConfig(eq(NEW_SCRIPT_ID), eq("globalScript"), eq(0), + scriptAttributes.capture()); + assertThat(scriptAttributes.getValue()) + .containsEntry("name", singleton("OAuth2 Access Token Modification Script")) + .containsEntry("context", singleton(NEW_CONTEXT)) + .containsEntry("language", singleton("GROOVY")) + .containsEntry("script", singleton("// access token modification script")); + + ArgumentCaptor>> contextAttributes = ArgumentCaptor.forClass(Map.class); + creationOrder.verify(globalConfig).addSubConfig(eq(NEW_CONTEXT), eq("scriptContext"), eq(0), + contextAttributes.capture()); + assertThat(contextAttributes.getValue()).containsEntry("defaultScript", singleton(NEW_SCRIPT_ID)); + + ArgumentCaptor>> engineAttributes = ArgumentCaptor.forClass(Map.class); + verify(createdContextConfig).addSubConfig(eq(ENGINE_CONFIGURATION), eq(ENGINE_CONFIGURATION), eq(0), + engineAttributes.capture()); + assertThat(engineAttributes.getValue().get("whiteList")).containsExactlyInAnyOrder( + "java.lang.Boolean", "java.lang.String", "org.forgerock.openam.oauth2.ScriptableAccessToken"); + + // The synthetic empty node exercises the id = name and null-attribute fallbacks. + ArgumentCaptor>> syntheticAttributes = ArgumentCaptor.forClass(Map.class); + verify(createdContextConfig).addSubConfig(eq(SYNTHETIC_EMPTY_CONFIG), eq(SYNTHETIC_EMPTY_CONFIG), eq(0), + syntheticAttributes.capture()); + assertThat(syntheticAttributes.getValue()).isEmpty(); + + verify(existingContextConfig, never()).addSubConfig(anyString(), anyString(), anyInt(), any(Map.class)); + } + + @Test + public void addsEngineConfigurationWhenContextExistsWithoutIt() throws Exception { + // For an absent entry whose name matches its sub-schema name (engineConfiguration), getSubConfig + // returns a non-null config wrapping a non-existent SMSEntry instead of null, e.g. after a manual + // ssoadm workaround created the context but not its engine configuration. + when(existingEngineConfig.exists()).thenReturn(false); + stubNewContextAndScriptAsExisting(); + + upgradeStep.initialize(); + + assertThat(upgradeStep.isApplicable()).isTrue(); + assertThat(upgradeStep.getShortReport("-")).isEqualTo("New Scripting Service configurations (1)-"); + assertThat(upgradeStep.getDetailedReport("-")).contains(EXISTING_CONTEXT + "/" + ENGINE_CONFIGURATION); + + upgradeStep.perform(); + + ArgumentCaptor>> engineAttributes = ArgumentCaptor.forClass(Map.class); + verify(existingContextConfig).addSubConfig(eq(ENGINE_CONFIGURATION), eq(ENGINE_CONFIGURATION), eq(0), + engineAttributes.capture()); + assertThat(engineAttributes.getValue()).containsEntry("whiteList", singleton("java.lang.String")); + + verify(globalConfig, never()).addSubConfig(anyString(), anyString(), anyInt(), any(Map.class)); + verify(globalScriptsConfig, never()).addSubConfig(anyString(), anyString(), anyInt(), any(Map.class)); + } + + @Test + public void skipsEntryCreatedBetweenInitializeAndPerform() throws Exception { + stubNewContextAndScriptAsExisting(); + // The script is missing during initialize() but has been created (e.g. by another step) by the time + // perform() runs, so the re-check must skip it instead of failing on a duplicate add. + ServiceConfig lateScriptConfig = mock(ServiceConfig.class); + when(lateScriptConfig.exists()).thenReturn(true); + when(globalScriptsConfig.getSubConfig(NEW_SCRIPT_ID)).thenReturn(null, lateScriptConfig); + + upgradeStep.initialize(); + assertThat(upgradeStep.isApplicable()).isTrue(); + + upgradeStep.perform(); + + verify(globalScriptsConfig, never()).addSubConfig(anyString(), anyString(), anyInt(), any(Map.class)); + } + + @Test + public void failsWhenParentConfigurationDisappearsBeforePerform() throws Exception { + when(existingEngineConfig.exists()).thenReturn(false); + stubNewContextAndScriptAsExisting(); + + upgradeStep.initialize(); + assertThat(upgradeStep.isApplicable()).isTrue(); + + when(globalConfig.getSubConfig(EXISTING_CONTEXT)).thenReturn(null); + + assertThatThrownBy(() -> upgradeStep.perform()) + .isInstanceOf(UpgradeException.class) + .hasMessageContaining(EXISTING_CONTEXT + "/" + ENGINE_CONFIGURATION); + } + + @Test + public void notApplicableWhenScriptingServiceHasNoGlobalConfig() throws Exception { + globalConfig = null; + + upgradeStep.initialize(); + + assertThat(upgradeStep.isApplicable()).isFalse(); + assertThat(upgradeStep.getShortReport("-")).isEmpty(); + } + + @Test + public void notApplicableWhenServiceDefinitionHasNoGlobalConfiguration() throws Exception { + upgradeStep = new SafeUpgradeScriptingSubConfigsStep(adminTokenAction, connectionFactory) { + @Override + protected Document getScriptingServiceXML() { + return XMLUtils.toDOMDocument( + "" + + "", null); + } + }; + + upgradeStep.initialize(); + + assertThat(upgradeStep.isApplicable()).isFalse(); + } + + @Test + public void doesNothingWhenAllSubConfigsExist() throws Exception { + ServiceConfig newContextConfig = stubNewContextAndScriptAsExisting(); + + upgradeStep.initialize(); + + assertThat(upgradeStep.isApplicable()).isFalse(); + assertThat(upgradeStep.getShortReport("-")).isEmpty(); + + upgradeStep.perform(); + + verify(globalConfig, never()).addSubConfig(anyString(), anyString(), anyInt(), any(Map.class)); + verify(globalScriptsConfig, never()).addSubConfig(anyString(), anyString(), anyInt(), any(Map.class)); + verify(newContextConfig, never()).addSubConfig(anyString(), anyString(), anyInt(), any(Map.class)); + } + + private ServiceConfig stubNewContextAndScriptAsExisting() throws Exception { + ServiceConfig newContextConfig = mock(ServiceConfig.class); + ServiceConfig newEngineConfig = mock(ServiceConfig.class); + ServiceConfig newSyntheticConfig = mock(ServiceConfig.class); + ServiceConfig newScriptConfig = mock(ServiceConfig.class); + when(globalConfig.getSubConfig(NEW_CONTEXT)).thenReturn(newContextConfig); + when(newContextConfig.getSubConfig(ENGINE_CONFIGURATION)).thenReturn(newEngineConfig); + when(newContextConfig.getSubConfig(SYNTHETIC_EMPTY_CONFIG)).thenReturn(newSyntheticConfig); + when(globalScriptsConfig.getSubConfig(NEW_SCRIPT_ID)).thenReturn(newScriptConfig); + when(newContextConfig.exists()).thenReturn(true); + when(newEngineConfig.exists()).thenReturn(true); + when(newSyntheticConfig.exists()).thenReturn(true); + when(newScriptConfig.exists()).thenReturn(true); + return newContextConfig; + } + + /** + * Test class with the data store and service definition access mocked out, so to work against the test + * xml and the mocked global configuration instead. + */ + private class SafeUpgradeScriptingSubConfigsStep extends UpgradeScriptingSubConfigsStep { + + SafeUpgradeScriptingSubConfigsStep(PrivilegedAction adminTokenAction, + ConnectionFactory connectionFactory) { + super(adminTokenAction, connectionFactory); + } + + @Override + protected Document getScriptingServiceXML() throws UpgradeException { + try { + return XMLUtils.getXMLDocument(ClassLoader.getSystemResourceAsStream("test-scripting.xml")); + } catch (Exception e) { + throw new UpgradeException(e); + } + } + + @Override + protected ServiceConfig getScriptingGlobalConfig() { + return globalConfig; + } + } +} diff --git a/openam-upgrade/src/test/resources/test-scripting.xml b/openam-upgrade/src/test/resources/test-scripting.xml new file mode 100644 index 0000000000..aa469677a8 --- /dev/null +++ b/openam-upgrade/src/test/resources/test-scripting.xml @@ -0,0 +1,95 @@ + + + + + + + + + + 36863ffb-40ec-48b9-94b1-9a99f71cc3b5 + + + + + java.lang.String + + + + + + + d22f9a0c-426a-4466-b95e-d0f125b0d5fa + + + + + java.lang.Boolean + java.lang.String + org.forgerock.openam.oauth2.ScriptableAccessToken + + + + + + + + + + OIDC Claims Script + + + + OIDC_CLAIMS + + + + GROOVY + + + + // OIDC claims script + + + + + + OAuth2 Access Token Modification Script + + + + Default global script for OAuth2 Access Token Modification + + + + OAUTH2_ACCESS_TOKEN_MODIFICATION + + + + GROOVY + + + + // access token modification script + + + + + + +