diff --git a/src/main/java/com/cloudbees/plugins/credentials/CredentialsMatchers.java b/src/main/java/com/cloudbees/plugins/credentials/CredentialsMatchers.java index 3b9c0d99..2010bda6 100644 --- a/src/main/java/com/cloudbees/plugins/credentials/CredentialsMatchers.java +++ b/src/main/java/com/cloudbees/plugins/credentials/CredentialsMatchers.java @@ -108,6 +108,8 @@ public static CredentialsMatcher instanceOf(@NonNull Class clazz) { * @param id the {@link com.cloudbees.plugins.credentials.common.IdCredentials#getId()} to match. * @return a matcher that matches {@link com.cloudbees.plugins.credentials.common.IdCredentials} with the * supplied {@link com.cloudbees.plugins.credentials.common.IdCredentials#getId()} + * @see CredentialsProvider#findCredentialByIdInItem + * @see CredentialsProvider#findCredentialByIdInItemGroup */ @NonNull public static CredentialsMatcher withId(@NonNull String id) { diff --git a/src/main/java/com/cloudbees/plugins/credentials/CredentialsProvider.java b/src/main/java/com/cloudbees/plugins/credentials/CredentialsProvider.java index 1fad1ef4..63e98903 100644 --- a/src/main/java/com/cloudbees/plugins/credentials/CredentialsProvider.java +++ b/src/main/java/com/cloudbees/plugins/credentials/CredentialsProvider.java @@ -383,12 +383,14 @@ public static List lookupCredentialsInItemGroup(@NonN Set ids = new HashSet<>(); for (CredentialsProvider provider : all()) { if (provider.isEnabled(itemGroup) && provider.isApplicable(type)) { + LOGGER.fine(() -> "checking " + provider + " for " + type); try { for (C c : provider.getCredentialsInItemGroup(type, itemGroup, authentication, domainRequirements)) { if (!(c instanceof IdCredentials) || ids.add(((IdCredentials) c).getId())) { // if IdCredentials, only add if we haven't added already // if not IdCredentials, always add result.add(c); + LOGGER.fine(() -> "got " + c + " from " + provider); } } } catch (NoClassDefFoundError e) { @@ -557,12 +559,14 @@ public static List lookupCredentialsInItem(@NonNull C Set ids = new HashSet<>(); for (CredentialsProvider provider : all()) { if (provider.isEnabled(item) && provider.isApplicable(type)) { + LOGGER.fine(() -> "checking " + provider + " for " + type); try { for (C c: provider.getCredentialsInItem(type, item, authentication, domainRequirements)) { if (!(c instanceof IdCredentials) || ids.add(((IdCredentials) c).getId())) { // if IdCredentials, only add if we haven't added already // if not IdCredentials, always add result.add(c); + LOGGER.fine(() -> "got " + c + " from " + provider); } } } catch (NoClassDefFoundError e) { @@ -844,6 +848,98 @@ public static C snapshot(Class clazz, C credential) { } } + /** + * Returns the credential with the specified ID which is available to the specified {@link Authentication} + * for use by the {@link Item}s in the specified {@link ItemGroup}. + * + * @param id the ID of the credential to find. + * @param type the type of credential to find. + * @param itemGroup the item group (if {@code null} assume {@link Jenkins#get()}). + * @param authentication the authentication (if {@code null} assume {@link ACL#SYSTEM2}). + * @param domainRequirements the credential domains to match (if {@code null} assume empty list). + * @param the credentials type. + * @return the credential or {@code null} if no credential with the specified ID is found. + */ + @CheckForNull + public static C findCredentialByIdInItemGroup(@NonNull String id, + @NonNull Class type, + @Nullable ItemGroup itemGroup, + @Nullable Authentication authentication, + @Nullable List domainRequirements) { + Objects.requireNonNull(id); + Objects.requireNonNull(type); + if (itemGroup == null) { + itemGroup = Jenkins.get(); + } + if (authentication == null) { + authentication = ACL.SYSTEM2; + } + if (domainRequirements == null) { + domainRequirements = List.of(); + } + var g = itemGroup; + LOGGER.fine(() -> "looking for " + id + " of " + type + " in " + g); + for (CredentialsProvider provider : all()) { + if (provider.isEnabled(itemGroup) && provider.isApplicable(type)) { + LOGGER.fine(() -> "checking " + provider + " for " + id); + C credential = provider.getCredentialByIdInItemGroup(id, type, itemGroup, authentication, domainRequirements); + if (credential != null) { + LOGGER.fine(() -> "found " + credential + " in " + provider); + return credential; + } + } + } + LOGGER.fine(() -> "did not find " + id); + return null; + } + + /** + * Returns the credential with the specified ID which is available to the specified {@link Authentication} + * for use by the specified {@link Item}. + * + * @param id the ID of the credential to find. + * @param type the type of credential to find. + * @param item the item (if {@code null} assume {@link Jenkins#get()}). + * @param authentication the authentication (if {@code null} assume {@link ACL#SYSTEM2}). + * @param domainRequirements the credential domains to match (if {@code null} assume empty list). + * @param the credentials type. + * @return the credential or {@code null} if no credential with the specified ID is found. + */ + @CheckForNull + public static C findCredentialByIdInItem(@NonNull String id, + @NonNull Class type, + @Nullable Item item, + @Nullable Authentication authentication, + @Nullable List domainRequirements) { + Objects.requireNonNull(id); + Objects.requireNonNull(type); + if (item == null) { + return findCredentialByIdInItemGroup(id, type, Jenkins.get(), authentication, domainRequirements); + } + if (item instanceof ItemGroup group) { + return findCredentialByIdInItemGroup(id, type, group, authentication, domainRequirements); + } + if (authentication == null) { + authentication = ACL.SYSTEM2; + } + if (domainRequirements == null) { + domainRequirements = List.of(); + } + LOGGER.fine(() -> "looking for " + id + " of " + type + " in " + item); + for (CredentialsProvider provider : all()) { + if (provider.isEnabled(item) && provider.isApplicable(type)) { + LOGGER.fine(() -> "checking " + provider + " for " + id); + C credential = provider.getCredentialByIdInItem(id, type, item, authentication, domainRequirements); + if (credential != null) { + LOGGER.fine(() -> "found " + credential + " in " + provider); + return credential; + } + } + } + LOGGER.fine(() -> "did not find " + id); + return null; + } + /** * A common requirement for plugins is to resolve a specific credential by id in the context of a specific run. * Given that the credential itself could be resulting from a build parameter expression and the complexities of @@ -925,55 +1021,46 @@ public static C findCredentialById(@NonNull String id, // as you would have no way to configure it Authentication runAuth = CredentialsProvider.getDefaultAuthenticationOf2(run.getParent()); // we want the credentials available to the user the build is running as - List candidates = new ArrayList<>( - CredentialsProvider.lookupCredentialsInItem(type, run.getParent(), runAuth, domainRequirements) - ); - // if that user can use the item's credentials, add those in too - if (runAuth != ACL.SYSTEM2 && run.hasPermission2(runAuth, CredentialsProvider.USE_ITEM)) { - candidates.addAll( - CredentialsProvider.lookupCredentialsInItem(type, run.getParent(), ACL.SYSTEM2, domainRequirements) - ); + C credential = findCredentialByIdInItem(id, type, run.getParent(), runAuth, domainRequirements); + // if that user can use the item's credentials, try those too + if (credential == null && runAuth != ACL.SYSTEM2 && run.hasPermission2(runAuth, CredentialsProvider.USE_ITEM)) { + credential = findCredentialByIdInItem(id, type, run.getParent(), ACL.SYSTEM2, domainRequirements); } // TODO should this be calling track? - return contextualize(type, CredentialsMatchers.firstOrNull(candidates, CredentialsMatchers.withId(id)), run); + return contextualize(type, credential, run); } // this is a parameter and not the default value, we need to determine who triggered the build final Map.Entry> triggeredBy = triggeredBy(run); final Authentication a = triggeredBy == null ? Jenkins.ANONYMOUS2 : triggeredBy.getKey().impersonate2(); - List candidates = new ArrayList<>(); + C result = null; if (triggeredBy != null && run == triggeredBy.getValue() && run.hasPermission2(a, CredentialsProvider.USE_OWN)) { // the user triggered this job directly and they are allowed to supply their own credentials, so - // add those into the list. We do not want to follow the chain for the user's authentication + // search those first. We do not want to follow the chain for the user's authentication // though, as there is no way to limit how far the passed-through parameters can be used - candidates.addAll(CredentialsProvider.lookupCredentialsInItem(type, run.getParent(), a, domainRequirements)); + result = findCredentialByIdInItem(id, type, run.getParent(), a, domainRequirements); } - if (inputUserId != null) { + if (result == null && inputUserId != null) { final User inputUser = User.getById(inputUserId, false); if (inputUser != null) { final Authentication inputAuth = inputUser.impersonate2(); if (run.hasPermission2(inputAuth, CredentialsProvider.USE_OWN)) { - candidates.addAll(CredentialsProvider.lookupCredentialsInItem(type, run.getParent(), inputAuth, domainRequirements)); + result = findCredentialByIdInItem(id, type, run.getParent(), inputAuth, domainRequirements); } } } - if (run.hasPermission2(a, CredentialsProvider.USE_ITEM)) { - // the triggering user is allowed to use the item's credentials, so add those into the list + if (result == null && run.hasPermission2(a, CredentialsProvider.USE_ITEM)) { + // the triggering user is allowed to use the item's credentials, so search those // we use the default authentication of the job as those are the only ones that can be configured // if a different strategy is in play it doesn't make sense to consider the run-time authentication // as you would have no way to configure it Authentication runAuth = CredentialsProvider.getDefaultAuthenticationOf2(run.getParent()); // we want the credentials available to the user the build is running as - candidates.addAll( - CredentialsProvider.lookupCredentialsInItem(type, run.getParent(), runAuth, domainRequirements) - ); - // if that user can use the item's credentials, add those in too - if (runAuth != ACL.SYSTEM2 && run.hasPermission2(runAuth, CredentialsProvider.USE_ITEM)) { - candidates.addAll( - CredentialsProvider.lookupCredentialsInItem(type, run.getParent(), ACL.SYSTEM2, domainRequirements) - ); + result = findCredentialByIdInItem(id, type, run.getParent(), runAuth, domainRequirements); + // if that user can use the item's credentials, try those too + if (result == null && runAuth != ACL.SYSTEM2 && run.hasPermission2(runAuth, CredentialsProvider.USE_ITEM)) { + result = findCredentialByIdInItem(id, type, run.getParent(), ACL.SYSTEM2, domainRequirements); } } - C result = CredentialsMatchers.firstOrNull(candidates, CredentialsMatchers.withId(id)); // if the run has not completed yet then we can safely assume that the credential is being used for this run // so we will track it's usage. We use isLogUpdated() as it could be used during post production if (run.isLogUpdated()) { @@ -1208,6 +1295,64 @@ public ListBoxModel getCredentialIds(@NonNull Class return getCredentialIdsInItemGroup(type, itemGroup, authentication == null ? null : authentication.toSpring(), domainRequirements, matcher); } + /** + * Returns the credential with the specified ID provided by this provider which is available to the + * specified {@link Authentication} for items in the specified {@link ItemGroup} and is appropriate for the + * specified {@link DomainRequirement}s. + * NOTE: implementations are recommended to override this method if the actual secret information + * is being stored external from Jenkins and looking up by ID can avoid loading credentials that do not match. + * The default implementation uses {@link #getCredentialsInItemGroup(Class, ItemGroup, Authentication, List)} + * and filters the results. + * + * @param the credentials type. + * @param id the ID of the credential to find. + * @param type the type of credentials to return. + * @param itemGroup the item group. + * @param authentication the authentication. + * @param domainRequirements the credential domain to match. + * @return the credential or {@code null} if no credential with the specified ID is found. + */ + @CheckForNull + public C getCredentialByIdInItemGroup(@NonNull String id, + @NonNull Class type, + @NonNull ItemGroup itemGroup, + @NonNull Authentication authentication, + @NonNull List domainRequirements) { + return CredentialsMatchers.firstOrNull( + getCredentialsInItemGroup(type, itemGroup, authentication, domainRequirements), + CredentialsMatchers.withId(id) + ); + } + + /** + * Returns the credential with the specified ID provided by this provider which is available to the + * specified {@link Authentication} for the specified {@link Item} and is appropriate for the + * specified {@link DomainRequirement}s. + * NOTE: implementations are recommended to override this method if the actual secret information + * is being stored external from Jenkins and looking up by ID can avoid loading credentials that do not match. + * The default implementation uses {@link #getCredentialsInItem(Class, Item, Authentication, List)} + * and filters the results. + * + * @param the credentials type. + * @param id the ID of the credential to find. + * @param type the type of credentials to return. + * @param item the item. + * @param authentication the authentication. + * @param domainRequirements the credential domain to match. + * @return the credential or {@code null} if no credential with the specified ID is found. + */ + @CheckForNull + public C getCredentialByIdInItem(@NonNull String id, + @NonNull Class type, + @NonNull Item item, + @NonNull Authentication authentication, + @NonNull List domainRequirements) { + return CredentialsMatchers.firstOrNull( + getCredentialsInItem(type, item, authentication, domainRequirements), + CredentialsMatchers.withId(id) + ); + } + /** * Returns a {@link ListBoxModel} of the credentials provided by this provider which are available to the * specified {@link Authentication} for items in the specified {@link ItemGroup} and are appropriate for the diff --git a/src/test/java/com/cloudbees/plugins/credentials/ByIdTest.java b/src/test/java/com/cloudbees/plugins/credentials/ByIdTest.java new file mode 100644 index 00000000..dde6683a --- /dev/null +++ b/src/test/java/com/cloudbees/plugins/credentials/ByIdTest.java @@ -0,0 +1,176 @@ +/* + * The MIT License + * + * Copyright 2026 CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package com.cloudbees.plugins.credentials; + +import com.cloudbees.plugins.credentials.common.IdCredentials; +import com.cloudbees.plugins.credentials.domains.DomainRequirement; +import com.cloudbees.plugins.credentials.impl.UsernamePasswordCredentialsImpl; +import edu.umd.cs.findbugs.annotations.CheckForNull; +import hudson.ExtensionList; +import hudson.model.Item; +import hudson.model.ItemGroup; +import java.util.ArrayList; +import java.util.List; +import org.springframework.security.core.Authentication; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.jupiter.api.Assertions.assertEquals; +import org.junit.jupiter.api.Test; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.TestExtension; +import org.jvnet.hudson.test.junit.jupiter.WithJenkins; + +/** + * Exercises {@link CredentialsProvider} searches for {@link IdCredentials}. + */ +@SuppressWarnings("rawtypes") // historical mistake with ItemGroup +@WithJenkins +final class ByIdTest { + + private LazyProvider2 lp2; + private LazyProvider3 lp3; + + private void setUp() throws Exception { + // Verify that our test providers are at the end of the list (they start with "ZZZ"); will be after folder, system, mock, user + assertThat(CredentialsProvider.all().get(CredentialsProvider.all().size() - 3), instanceOf(LazyProvider1.class)); + assertThat(CredentialsProvider.all().get(CredentialsProvider.all().size() - 2), instanceOf(LazyProvider2.class)); + assertThat(CredentialsProvider.all().get(CredentialsProvider.all().size() - 1), instanceOf(LazyProvider3.class)); + + lp2 = ExtensionList.lookupSingleton(LazyProvider2.class); + lp3 = ExtensionList.lookupSingleton(LazyProvider3.class); + + // Add credentials to lazy providers + lp2.credentials.add(new UsernamePasswordCredentialsImpl(CredentialsScope.GLOBAL, "lazy2-cred", null, "user", "pass")); + lp3.credentials.add(new UsernamePasswordCredentialsImpl(CredentialsScope.GLOBAL, "lazy3-cred", null, "user", "pass")); + + // Reset counters + lp2.getByIdCalls = 0; + lp2.listCalls = 0; + lp3.listCalls = 0; + } + + @Test void lazyEvaluationWithEarlyMatch(JenkinsRule r) throws Exception { + setUp(); + + // Add a credential to the system store (early provider) + SystemCredentialsProvider.getInstance().getCredentials().add(new UsernamePasswordCredentialsImpl(CredentialsScope.GLOBAL, "early-cred", null, "user", "pass")); + SystemCredentialsProvider.getInstance().save(); + + // Search for early credential + var result = CredentialsProvider.findCredentialByIdInItemGroup("early-cred", IdCredentials.class, null, null, null); + + assertThat(result, notNullValue()); + assertThat(result.getId(), is("early-cred")); + + // Verify lazy providers were not consulted + assertEquals(0, lp2.getByIdCalls, "LazyProvider2.getCredentialById should not be called"); + assertEquals(0, lp2.listCalls, "LazyProvider2.getCredentialsInItemGroup should not be called"); + assertEquals(0, lp3.listCalls, "LazyProvider3.getCredentialsInItemGroup should not be called"); + } + + @Test void lazyEvaluationWithOptimizedProvider(JenkinsRule r) throws Exception { + setUp(); + var result = CredentialsProvider.findCredentialByIdInItemGroup("lazy2-cred", IdCredentials.class, null, null, null); + assertThat(result, notNullValue()); + assertThat(result.getId(), is("lazy2-cred")); + assertEquals(1, lp2.getByIdCalls, "LazyProvider2.getCredentialById should be called once"); + assertEquals(0, lp2.listCalls, "LazyProvider2.getCredentialsInItemGroup should not be called"); + assertEquals(0, lp3.listCalls, "LazyProvider3.getCredentialsInItemGroup should not be called"); + } + + @Test void lazyEvaluationWithUnoptimizedProvider(JenkinsRule r) throws Exception { + setUp(); + var result = CredentialsProvider.findCredentialByIdInItemGroup("lazy3-cred", IdCredentials.class, null, null, null); + assertThat(result, notNullValue()); + assertThat(result.getId(), is("lazy3-cred")); + assertEquals(1, lp2.getByIdCalls, "LazyProvider2.getCredentialById should be called once"); + assertEquals(0, lp2.listCalls, "LazyProvider2.getCredentialsInItemGroup should not be called (uses optimized path)"); + assertEquals(1, lp3.listCalls, "LazyProvider3.getCredentialsInItemGroup should be called once"); + } + + @Test void lazyEvaluationWithNonexistentCredential(JenkinsRule r) throws Exception { + setUp(); + var result = CredentialsProvider.findCredentialByIdInItemGroup("nonexistent", IdCredentials.class, null, null, null); + assertThat(result, nullValue()); + assertEquals(1, lp2.getByIdCalls, "LazyProvider2.getCredentialById should be called once"); + assertEquals(0, lp2.listCalls, "LazyProvider2.getCredentialsInItemGroup should not be called (uses optimized path)"); + assertEquals(1, lp3.listCalls, "LazyProvider3.getCredentialsInItemGroup should be called once"); + } + + @TestExtension public static final class LazyProvider1 extends CredentialsProvider { + // @TestExtension lacks ordinal, and CredentialsProvider.getDisplayName uses Class.simpleName, + // so to sort CredentialsProvider.all we must use a special nested class name or override this: + @Override public String getDisplayName() { + return "ZZZ Lazy Provider #1"; + } + @Override public List getCredentialsInItemGroup(Class type, ItemGroup itemGroup, Authentication authentication, List domainRequirements) { + return List.of(); + } + } + + @TestExtension public static final class LazyProvider2 extends CredentialsProvider { + final List credentials = new ArrayList<>(); + int getByIdCalls = 0; + int listCalls = 0; + @Override public String getDisplayName() { + return "ZZZ Lazy Provider #2 (Optimized)"; + } + @Override public C getCredentialByIdInItemGroup(String id, Class type, ItemGroup itemGroup, Authentication authentication, List domainRequirements) { + getByIdCalls++; + return find(id, type); + } + @Override public C getCredentialByIdInItem(String id, Class type, Item item, Authentication authentication, List domainRequirements) { + getByIdCalls++; + return find(id, type); + } + private @CheckForNull C find(String id, Class type) { + return type.cast(credentials.stream().filter(cred -> cred.getId().equals(id) && type.isInstance(cred)).findAny().orElse(null)); + } + @Override public List getCredentialsInItemGroup(Class type, ItemGroup itemGroup, Authentication authentication, List domainRequirements) { + listCalls++; + return filter(type, credentials); + } + } + + @TestExtension public static final class LazyProvider3 extends CredentialsProvider { + final List credentials = new ArrayList<>(); + int listCalls = 0; + @Override public String getDisplayName() { + return "ZZZ Lazy Provider #3 (Unoptimized)"; + } + @Override public List getCredentialsInItemGroup(Class type, ItemGroup itemGroup, Authentication authentication, List domainRequirements) { + listCalls++; + return filter(type, credentials); + } + } + + private static List filter(Class type, List credentials) { + return credentials.stream().filter(type::isInstance).map(type::cast).toList(); + } + +}