[java-shib-metadata] branch main updated: JSMD-14 - Filter to add/remove Scope extension

Codeberg noreply at shibboleth.net
Tue Jan 20 19:59:18 UTC 2026


This is an automated email from the git hooks/post-receive script.

codeberg pushed a commit to branch main
in repository java-shib-metadata.

View the commit online:
https://codeberg.org/Shibboleth/java-shib-metadata/commit/8717e1f639bfab518458f0e9e41b07138f085386

The following commit(s) were added to refs/heads/main by this push:
     new 8717e1f6 JSMD-14 - Filter to add/remove Scope extension
8717e1f6 is described below

commit 8717e1f639bfab518458f0e9e41b07138f085386
Author: Scott Cantor <cantor.2 at osu.edu>
AuthorDate: Tue Jan 20 14:59:07 2026 -0500

    JSMD-14 - Filter to add/remove Scope extension
    
    https://shibboleth.atlassian.net/browse/JSMD-14
---
 .../net/shibboleth/idp/saml/xmlobject/Scope.java   |   7 +-
 .../idp/saml/metadata/impl/ScopeFilter.java        | 229 +++++++++++++++++++++
 .../idp/saml/metadata/impl/ScopeFilterTest.java    | 196 ++++++++++++++++++
 3 files changed, 431 insertions(+), 1 deletion(-)

diff --git a/shib-metadata-api/src/main/java/net/shibboleth/idp/saml/xmlobject/Scope.java b/shib-metadata-api/src/main/java/net/shibboleth/idp/saml/xmlobject/Scope.java
index fe9773ca..a7a35faf 100644
--- a/shib-metadata-api/src/main/java/net/shibboleth/idp/saml/xmlobject/Scope.java
+++ b/shib-metadata-api/src/main/java/net/shibboleth/idp/saml/xmlobject/Scope.java
@@ -23,7 +23,12 @@ import org.opensaml.core.xml.schema.XSString;
 
 import net.shibboleth.shared.annotation.constraint.NotEmpty;
 
-/** XMLObject for the Shibboleth Scope metadata extension. */
+/**
+ * XMLObject for the Shibboleth Scope metadata extension.
+ * 
+ * <p>Note that this is at least nominally now standardized by virtue of being published
+ * in our old namespace in the SAML subject-id Attribute spec.</p>
+ */
 public interface Scope extends XSString {
 
     /** Element local name. */
diff --git a/shib-metadata-impl/src/main/java/net/shibboleth/idp/saml/metadata/impl/ScopeFilter.java b/shib-metadata-impl/src/main/java/net/shibboleth/idp/saml/metadata/impl/ScopeFilter.java
new file mode 100644
index 00000000..f46f6d63
--- /dev/null
+++ b/shib-metadata-impl/src/main/java/net/shibboleth/idp/saml/metadata/impl/ScopeFilter.java
@@ -0,0 +1,229 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.shibboleth.idp.saml.metadata.impl;
+
+import java.util.Collection;
+import java.util.Map;
+import java.util.function.Predicate;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.core.xml.XMLObject;
+import org.opensaml.core.xml.config.XMLObjectProviderRegistrySupport;
+import org.opensaml.core.xml.io.MarshallingException;
+import org.opensaml.core.xml.io.UnmarshallingException;
+import org.opensaml.core.xml.util.XMLObjectSupport;
+import org.opensaml.saml.common.SAMLObjectBuilder;
+import org.opensaml.saml.metadata.resolver.filter.AbstractMetadataFilter;
+import org.opensaml.saml.metadata.resolver.filter.FilterException;
+import org.opensaml.saml.metadata.resolver.filter.MetadataFilterContext;
+import org.opensaml.saml.saml2.metadata.EntitiesDescriptor;
+import org.opensaml.saml.saml2.metadata.EntityDescriptor;
+import org.opensaml.saml.saml2.metadata.Extensions;
+import org.opensaml.saml.saml2.metadata.RoleDescriptor;
+import org.slf4j.Logger;
+
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.Multimap;
+
+import net.shibboleth.idp.saml.xmlobject.Scope;
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+import net.shibboleth.shared.collection.CollectionSupport;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/**
+ * A filter that adds the {@link Scope} extension to entities in order to drive software
+ * behavior based on them.
+ * 
+ * <p>The entities to annotate are identified with a {@link Predicate}, and multiple scopes can be
+ * associated with each, with and without the regexp flag.</p>
+ * 
+ * <p>Note that this filter only operates on extensions defined at the {@link EntityDescriptor} level
+ * and not on individual roles. Role-specific scopes are discouraged in general.</p>
+ */
+public class ScopeFilter extends AbstractMetadataFilter {
+    
+    /** Used as a value in place of an empty collection when removing only. */
+    @Nonnull @NotEmpty private static final String GUARD_VALUE = "_EMPTY";
+    
+    /** Class logger. */
+    @Nonnull private final Logger log = LoggerFactory.getLogger(ScopeFilter.class);
+
+    /** Stand-in value for an empty collection. */
+    @Nonnull private final Scope guardObject;
+    
+    /** Whether to strip any existing Scopes when adding new ones. */
+    private boolean removeExistingScopes;
+    
+    /** Rules for adding scopes. */
+    @Nonnull private Multimap<Predicate<EntityDescriptor>,Scope> applyMap;
+
+    /** Builder for {@link Extensions}. */
+    @Nonnull private final SAMLObjectBuilder<Extensions> extBuilder;
+
+    /** Constructor. */
+    public ScopeFilter() {
+        guardObject = XMLObjectProviderRegistrySupport.getBuilderFactory().<Scope>ensureBuilder(
+                Scope.DEFAULT_ELEMENT_NAME).buildObject(Scope.DEFAULT_ELEMENT_NAME);
+        guardObject.setValue(GUARD_VALUE);
+        
+        extBuilder = (SAMLObjectBuilder<Extensions>)
+                XMLObjectProviderRegistrySupport.getBuilderFactory().<Extensions>ensureBuilder(
+                        Extensions.DEFAULT_ELEMENT_NAME);
+        applyMap = ArrayListMultimap.create();
+    }
+    
+    /**
+     * Set whether the filter should remove any existing scopes from an entity to which it adds new ones.
+     * 
+     * <p>Defaults to false (for compatibility).</p>
+     * 
+     * @param flag flag to set
+     */
+    public void setRemoveExistingScopes(final boolean flag) {
+        checkSetterPreconditions();
+        removeExistingScopes = flag;
+    }
+    
+    /**
+     * Set the mappings from {@link Predicate} to Scope collection to apply.
+     * 
+     * @param rules rules to apply
+     */
+    public void setRules(@Nonnull final Map<Predicate<EntityDescriptor>,Collection<Scope>> rules) {
+        checkSetterPreconditions();
+        Constraint.isNotNull(rules, "Rules map cannot be null");
+        
+        applyMap = ArrayListMultimap.create(rules.size(), 1);
+        for (final Map.Entry<Predicate<EntityDescriptor>,Collection<Scope>> entry : rules.entrySet()) {
+            if (entry.getKey() != null && entry.getValue() != null) {
+                applyMap.putAll(entry.getKey(),
+                        entry.getValue().isEmpty() ? CollectionSupport.singletonList(guardObject) :
+                            CollectionSupport.copyToList(entry.getValue()));
+            }
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Nullable public XMLObject filter(@Nullable final XMLObject metadata, @Nonnull final MetadataFilterContext context)
+            throws FilterException {
+        checkComponentActive();
+        if (metadata == null) {
+            return null;
+        }
+
+        if (metadata instanceof EntitiesDescriptor) {
+            filterEntitiesDescriptor((EntitiesDescriptor) metadata);
+        } else {
+            filterEntityDescriptor((EntityDescriptor) metadata);
+        }
+        
+        return metadata;
+    }
+    
+    /**
+     * Filters entity descriptor.
+     * 
+     * @param descriptor entity descriptor to filter
+     */
+    protected void filterEntityDescriptor(@Nonnull final EntityDescriptor descriptor) {
+        for (final Map.Entry<Predicate<EntityDescriptor>,Collection<Scope>> entry : applyMap.asMap().entrySet()) {
+            if (entry.getKey().test(descriptor)) {
+                // Since we only add to the roles, we need to delete anything at the entity level.
+                if (removeExistingScopes) {
+                    final Extensions exts = descriptor.getExtensions();
+                    if (exts != null) {
+                        final Collection<XMLObject> scopes = exts.getUnknownXMLObjects(Scope.DEFAULT_ELEMENT_NAME);
+                        if (!scopes.isEmpty()) {
+                            log.debug("Clearing existing entity-level Scope extensions on EntityDescriptor '{}'",
+                                    descriptor.getEntityID());
+                            scopes.clear();
+                        }
+                    }
+                }
+                for (final RoleDescriptor role : descriptor.getRoleDescriptors()) {
+                    assert role != null;
+                    filterRoleDescriptor(role, entry.getValue());
+                }
+            }
+        }
+    }
+            
+    /**
+     * Filters entities descriptor.
+     * 
+     * @param descriptor entities descriptor to filter
+     */
+    protected void filterEntitiesDescriptor(@Nonnull final EntitiesDescriptor descriptor) {
+        
+        // First we check any contained EntitiesDescriptors.
+        for (final EntitiesDescriptor group : descriptor.getEntitiesDescriptors()) {
+            assert group != null;
+            filterEntitiesDescriptor(group);
+        }
+        
+        // Next, check contained EntityDescriptors.
+        for (final EntityDescriptor entity : descriptor.getEntityDescriptors()) {
+            assert entity != null;
+            filterEntityDescriptor(entity);
+        }
+    }
+
+    /**
+     * Filters role descriptor.
+     * 
+     * @param role role descriptor to filter
+     * @param scopes scopes to add
+     */
+    protected void filterRoleDescriptor(@Nonnull final RoleDescriptor role, @Nonnull final Collection<Scope> scopes) {
+        final String entityID;
+        if (role.getParent() instanceof EntityDescriptor entity) {
+            entityID = entity.getEntityID();
+        } else {
+            entityID = null;
+        }
+        
+        Extensions exts = role.getExtensions();
+        if (exts != null && removeExistingScopes) {
+            log.debug("Removing existing Scope extensions from {} role in EntityDescriptor '{}'",
+                    role.getElementQName(), entityID);
+            exts.getUnknownXMLObjects(Scope.DEFAULT_ELEMENT_NAME).clear();
+        }
+        
+        for (final Scope scope : scopes) {
+            if (GUARD_VALUE.equals(scope.getValue())) {
+                // Skip guard value for empty collections.
+                continue;
+            }
+            
+            if (exts == null) {
+                // Need to add Extensions element.
+                exts = extBuilder.buildObject();
+                role.setExtensions(exts);
+            }
+
+            try {
+                log.info("Adding Scope '{}' to {} role in EntityDescriptor '{}'", scope.getValue(),
+                        role.getElementQName(), entityID);
+                exts.getUnknownXMLObjects(Scope.DEFAULT_ELEMENT_NAME).add(XMLObjectSupport.cloneXMLObject(scope));
+            } catch (final MarshallingException | UnmarshallingException e) {
+                log.error("Error cloning Scope", e);
+            }
+        }
+    }
+    
+}
\ No newline at end of file
diff --git a/shib-metadata-impl/src/test/java/net/shibboleth/idp/saml/metadata/impl/ScopeFilterTest.java b/shib-metadata-impl/src/test/java/net/shibboleth/idp/saml/metadata/impl/ScopeFilterTest.java
new file mode 100644
index 00000000..63192a73
--- /dev/null
+++ b/shib-metadata-impl/src/test/java/net/shibboleth/idp/saml/metadata/impl/ScopeFilterTest.java
@@ -0,0 +1,196 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.shibboleth.idp.saml.metadata.impl;
+
+import java.io.File;
+import java.net.URL;
+import java.util.Collection;
+import java.util.List;
+import java.util.function.Predicate;
+
+import net.shibboleth.idp.saml.xmlobject.Scope;
+import net.shibboleth.shared.collection.CollectionSupport;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.resolver.CriteriaSet;
+import net.shibboleth.shared.resolver.ResolverException;
+
+import org.opensaml.core.criterion.EntityIdCriterion;
+import org.opensaml.core.testing.XMLObjectBaseTestCase;
+import org.opensaml.core.xml.XMLObject;
+import org.opensaml.core.xml.XMLObjectBuilder;
+import org.opensaml.core.xml.config.XMLObjectProviderRegistrySupport;
+import org.opensaml.saml.metadata.resolver.impl.FilesystemMetadataResolver;
+import org.opensaml.saml.saml2.metadata.EntityDescriptor;
+import org.opensaml.saml.saml2.metadata.Extensions;
+import org.opensaml.saml.saml2.metadata.RoleDescriptor;
+import org.testng.Assert;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+ at SuppressWarnings("javadoc")
+public class ScopeFilterTest extends XMLObjectBaseTestCase implements Predicate<EntityDescriptor> {
+    
+    private XMLObjectBuilder<Scope> scopeBuilder;
+    
+    private FilesystemMetadataResolver metadataProvider;
+    
+    private File mdFile;
+    
+    private ScopeFilter metadataFilter;
+    
+    private Collection<Scope> scopesToAdd;
+    
+    @BeforeMethod
+    protected void setUp() throws Exception {
+
+        scopeBuilder =
+                XMLObjectProviderRegistrySupport.getBuilderFactory().<Scope>ensureBuilder(
+                        Scope.DEFAULT_ELEMENT_NAME);
+        
+        URL mdURL = ScopeFilterTest.class
+                .getResource("/net/shibboleth/idp/saml/impl/metadata/InCommon-metadata.xml");
+        mdFile = new File(mdURL.toURI());
+
+        metadataProvider = new FilesystemMetadataResolver(mdFile);
+        metadataProvider.setParserPool(parserPool);
+        
+        metadataFilter = new ScopeFilter();
+        
+        final Scope scope1 = scopeBuilder.buildObject(Scope.DEFAULT_ELEMENT_NAME);
+        scope1.setValue("scope1.org");
+        scope1.setRegexp(true);
+        
+        final Scope scope2 = scopeBuilder.buildObject(Scope.DEFAULT_ELEMENT_NAME);
+        scope2.setValue("scope2.org");
+        
+        scopesToAdd = CollectionSupport.listOf(scope1, scope2);
+    }
+    
+    @Test
+    public void test() throws ComponentInitializationException, ResolverException {
+        
+        metadataFilter.setRules(CollectionSupport.singletonMap(this, scopesToAdd));
+        metadataFilter.initialize();
+        
+        metadataProvider.setMetadataFilter(metadataFilter);
+        metadataProvider.setId("test");
+        metadataProvider.initialize();
+
+        final EntityIdCriterion key = new EntityIdCriterion("urn:mace:incommon:osu.edu");
+        EntityDescriptor entity = metadataProvider.resolveSingle(new CriteriaSet(key));
+        assert entity != null;
+     
+        for (final RoleDescriptor role : entity.getRoleDescriptors()) {
+            final Extensions exts = role.getExtensions();
+            assert exts != null;
+            final List<XMLObject> scopes = exts.getUnknownXMLObjects(Scope.DEFAULT_ELEMENT_NAME);
+            Assert.assertEquals(scopes.size(), 3);
+            if (scopes.get(0) instanceof Scope s) {
+                Assert.assertEquals(s.getValue(), "osu.edu");
+                Assert.assertEquals(s.getRegexp(), false);
+            } else {
+                Assert.fail("Scope was of unexpected type");
+            }
+            if (scopes.get(1) instanceof Scope s) {
+                Assert.assertEquals(s.getValue(), "scope1.org");
+                Assert.assertEquals(s.getRegexp(), true);
+            } else {
+                Assert.fail("Scope was of unexpected type");
+            }
+            if (scopes.get(2) instanceof Scope s) {
+                Assert.assertEquals(s.getValue(), "scope2.org");
+                Assert.assertEquals(s.getRegexp(), false);
+            } else {
+                Assert.fail("Scope was of unexpected type");
+            }
+        }
+    }
+
+    @Test
+    public void testRemovalOnly() throws ComponentInitializationException, ResolverException {
+        
+        metadataFilter.setRules(CollectionSupport.singletonMap(this, CollectionSupport.emptyList()));
+        metadataFilter.setRemoveExistingScopes(true);
+        metadataFilter.initialize();
+        
+        metadataProvider.setMetadataFilter(metadataFilter);
+        metadataProvider.setId("test");
+        metadataProvider.initialize();
+
+        final EntityIdCriterion key = new EntityIdCriterion("urn:mace:incommon:osu.edu");
+        EntityDescriptor entity = metadataProvider.resolveSingle(new CriteriaSet(key));
+        assert entity != null;
+
+        Extensions exts = entity.getExtensions();
+        if (exts != null) {
+            Assert.assertTrue(exts.getUnknownXMLObjects(Scope.DEFAULT_ELEMENT_NAME).isEmpty());
+        }
+        
+        for (final RoleDescriptor role : entity.getRoleDescriptors()) {
+            exts = role.getExtensions();
+            if (exts != null) {
+                Assert.assertTrue(exts.getUnknownXMLObjects(Scope.DEFAULT_ELEMENT_NAME).isEmpty());
+            }
+        }
+        
+    }
+    
+    @Test
+    public void testWithRemoval() throws ComponentInitializationException, ResolverException {
+        
+        metadataFilter.setRules(CollectionSupport.singletonMap(this, scopesToAdd));
+        metadataFilter.setRemoveExistingScopes(true);
+        metadataFilter.initialize();
+        
+        metadataProvider.setMetadataFilter(metadataFilter);
+        metadataProvider.setId("test");
+        metadataProvider.initialize();
+
+        final EntityIdCriterion key = new EntityIdCriterion("urn:mace:incommon:osu.edu");
+        final EntityDescriptor entity = metadataProvider.resolveSingle(new CriteriaSet(key));
+        assert entity != null;
+        
+        Extensions exts = entity.getExtensions();
+        if (exts != null) {
+            Assert.assertTrue(exts.getUnknownXMLObjects(Scope.DEFAULT_ELEMENT_NAME).isEmpty());
+        }
+
+        for (final RoleDescriptor role : entity.getRoleDescriptors()) {
+            exts = role.getExtensions();
+            assert exts != null;
+            final List<XMLObject> scopes = exts.getUnknownXMLObjects(Scope.DEFAULT_ELEMENT_NAME);
+            Assert.assertEquals(scopes.size(), 2);
+            if (scopes.get(0) instanceof Scope s) {
+                Assert.assertEquals(s.getValue(), "scope1.org");
+                Assert.assertEquals(s.getRegexp(), true);
+            } else {
+                Assert.fail("Scope was of unexpected type");
+            }
+            if (scopes.get(1) instanceof Scope s) {
+                Assert.assertEquals(s.getValue(), "scope2.org");
+                Assert.assertEquals(s.getRegexp(), false);
+            } else {
+                Assert.fail("Scope was of unexpected type");
+            }
+        }
+        
+    }
+
+    /** {@inheritDoc} */
+    public boolean test(EntityDescriptor input) {
+        return "urn:mace:incommon:osu.edu".equals(input.getEntityID());
+    }
+
+}
\ No newline at end of file

-- 
To stop receiving notification emails like this one, please contact
the administrator of this repository.


More information about the commits mailing list