Skip to content

Commit

Permalink
Merge pull request #119 from jvz/user-scoped-credentials-extension
Browse files Browse the repository at this point in the history
[JENKINS-58170] Allow credential parameters to shadow credential ids in lookup
  • Loading branch information
jvz committed Aug 26, 2019
2 parents 6d79efc + b35bdc0 commit 43be901
Show file tree
Hide file tree
Showing 16 changed files with 629 additions and 36 deletions.
19 changes: 19 additions & 0 deletions docs/consumer.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -477,3 +477,22 @@ IssueTrackerAuthentication auth = AuthenticationTokens.convert(
)
);
----

=== Binding user supplied credentials parameters to builds

A running build can be supplied with credentials parameter values.
Typically, these values are provided by using a parameterized job and supplying those parameters when triggering the build.
Additional credentials parameters may be specified by adding or updating a build's `CredentialsParameterBinder` build action.
Each parameter value can be associated to a user who supplied the credentials which allows for multiple sources of credentials to contribute to a build throughout its lifecycle.
Plugins acting on a build can do the following:

[source,java]
----
import com.cloudbees.plugins.credentials.builds.CredentialsParameterBinder;
CredentialsParameterBinder binder = CredentialsParameterBinder.getOrCreate(build);
binder.bindCredentialsParameter(userId, parameterValue); // <1>
binder.unbindCredentialsParameter(parameterName); // <2>
----
<1> The `userId` is only required if the parameter value references user credentials.
<2> Credentials parameters can optionally be unbound from a build to limit the scope of the credentials.
Binary file modified docs/images/build-with-parameters.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 11 additions & 1 deletion docs/user.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -1521,7 +1521,10 @@ The list of available credentials will depend on a number of factors:

[WARNING]
====
Credentials parameters can only access the per-user credentials store of the job that was explicitly triggered by the user.
Builds can only resolve user credentials if their owner provides them as a credentials parameter value.
The triggering user has access to provide their own user credentials as build parameters.
Credentials parameters can also be attached to a build by a plugin with an optional user ID which simulates the same.
These credentials parameters are scoped to their submitting users only for that build.
Downstream jobs will be passed the credentials ID but will not be passed access to the user's per-user credentials store.
This restriction is to prevent a malicious actor adding a hidden job as a downstream job and thereby gaining access to the per-user credentials store.
Expand All @@ -1530,6 +1533,13 @@ This restriction is to prevent a malicious actor adding a hidden job as a downst
The credentials parameter value will be the ID of the selected credentials.
Use the link:https://plugins.jenkins.io/credentials-binding[Credentials Binding] plugin if you need to get access to the secrets of a credentials instance, for example to use a password for authenticating a request to a third party system.

[INFO]
====
The credentials parameter name becomes a pseudo credentials ID when credentials are resolved during a build.
This allows for existing jobs and pipelines that use global or folder credentials to be easily updated to use user credentials by simply adding a credentials parameter to a build with a name matching the existing credentials ID.
The existing global or folder credentials can be used as default values or removed entirely after this migration.
====

==== Configuration as code (aka jcasc)
This plugin supports the https://github.com/jenkinsci/configuration-as-code-plugin[configuration-code-plugin] to manage credentials through yaml

Expand Down
29 changes: 27 additions & 2 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,22 @@
</pluginRepository>
</pluginRepositories>

<dependencyManagement>
<dependencies>
<dependency>
<groupId>io.jenkins.tools.bom</groupId>
<artifactId>bom</artifactId>
<version>2.138.1</version>
<scope>import</scope>
<type>pom</type>
</dependency>
</dependencies>
</dependencyManagement>

<dependencies>
<dependency>
<groupId>org.jenkins-ci.plugins</groupId>
<artifactId>structs</artifactId>
<version>1.7</version>
</dependency>
<dependency>
<groupId>io.jenkins</groupId>
Expand All @@ -104,7 +115,6 @@
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>1.9.5</version>
<scope>test</scope>
<exclusions>
<exclusion>
Expand Down Expand Up @@ -143,6 +153,21 @@
<classifier>tests</classifier>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.jenkins-ci.plugins.workflow</groupId>
<artifactId>workflow-job</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.jenkins-ci.plugins.workflow</groupId>
<artifactId>workflow-cps</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.jenkins-ci.plugins.workflow</groupId>
<artifactId>workflow-basic-steps</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ private Class<? extends StandardCredentials> decodeType(String credentialType) {
continue;
}
if (credentialType.equals(descriptor.clazz.getName())) {
return (Class<? extends StandardCredentials>) descriptor.clazz;
return descriptor.clazz.asSubclass(StandardCredentials.class);
}
}
return StandardCredentials.class;
Expand All @@ -167,7 +167,7 @@ public StandardListBoxModel doFillDefaultValueItems(@AncestorInPath Item context
Jenkins jenkins = Jenkins.get();
final ACL acl = context == null ? jenkins.getACL() : context.getACL();
final Class<? extends StandardCredentials> typeClass = decodeType(credentialType);
final List<DomainRequirement> domainRequirements = Collections.<DomainRequirement>emptyList();
final List<DomainRequirement> domainRequirements = Collections.emptyList();
final StandardListBoxModel result = new StandardListBoxModel();
result.includeEmptyValue();
if (acl.hasPermission(CredentialsProvider.USE_ITEM)) {
Expand All @@ -179,19 +179,20 @@ public StandardListBoxModel doFillDefaultValueItems(@AncestorInPath Item context
public StandardListBoxModel doFillValueItems(@AncestorInPath Item context,
@QueryParameter(required = true) String credentialType,
@QueryParameter String value,
@QueryParameter boolean required) {
@QueryParameter boolean required,
@QueryParameter boolean includeUser) {
Jenkins jenkins = Jenkins.get();
final ACL acl = context == null ? jenkins.getACL() : context.getACL();
final Authentication authentication = Jenkins.getAuthentication();
final Authentication itemAuthentication = CredentialsProvider.getDefaultAuthenticationOf(context);
final boolean isSystem = ACL.SYSTEM.equals(authentication);
final Class<? extends StandardCredentials> typeClass = decodeType(credentialType);
final List<DomainRequirement> domainRequirements = Collections.<DomainRequirement>emptyList();
final List<DomainRequirement> domainRequirements = Collections.emptyList();
final StandardListBoxModel result = new StandardListBoxModel();
if (!required) {
result.includeEmptyValue();
}
if (!isSystem && acl.hasPermission(CredentialsProvider.USE_OWN)) {
if (!isSystem && acl.hasPermission(CredentialsProvider.USE_OWN) && includeUser) {
result.includeAs(authentication, context, typeClass, domainRequirements);
}
if (acl.hasPermission(CredentialsProvider.USE_ITEM) || isSystem || itemAuthentication
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
*/
package com.cloudbees.plugins.credentials;

import com.cloudbees.plugins.credentials.builds.CredentialsParameterBinding;
import com.cloudbees.plugins.credentials.builds.CredentialsParameterBinder;
import com.cloudbees.plugins.credentials.common.IdCredentials;
import com.cloudbees.plugins.credentials.domains.DomainRequirement;
import com.cloudbees.plugins.credentials.fingerprints.ItemCredentialsFingerprintFacet;
Expand All @@ -48,8 +50,6 @@
import hudson.model.Job;
import hudson.model.ModelObject;
import hudson.model.Node;
import hudson.model.ParameterValue;
import hudson.model.ParametersAction;
import hudson.model.Queue;
import hudson.model.Run;
import hudson.model.User;
Expand Down Expand Up @@ -80,6 +80,7 @@
import java.util.Locale;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;
Expand Down Expand Up @@ -868,40 +869,44 @@ public static <C extends IdCredentials> C findCredentialById(@NonNull String id,
public static <C extends IdCredentials> C findCredentialById(@NonNull String id, @NonNull Class<C> type,
@NonNull Run<?, ?> run,
@Nullable List<DomainRequirement> domainRequirements) {
id.getClass(); // throw NPE if null;
type.getClass(); // throw NPE if null;
run.getClass(); // throw NPE if null;
Objects.requireNonNull(id);
Objects.requireNonNull(type);
Objects.requireNonNull(run);

// first we need to find out if this id is pre-selected or a parameter
id = id.trim();
boolean isParameter = false;
boolean isDefaultValue = false;
String inputUserId = null;
final String parameterName;
if (id.startsWith("${") && id.endsWith("}")) {
final ParametersAction action = run.getAction(ParametersAction.class);
if (action != null) {
final ParameterValue parameter = action.getParameter(id.substring(2, id.length() - 1));
if (parameter instanceof CredentialsParameterValue) {
isParameter = true;
isDefaultValue = ((CredentialsParameterValue) parameter).isDefaultValue();
id = ((CredentialsParameterValue) parameter).getValue();
// Avoid spotbugs complaining about the id being null and used lately on CredentialsMatchers.withId
id = (id == null) ? "" : id;
}
}
// denotes explicitly that this is a parameterized credential
parameterName = id.substring(2, id.length() - 1);
} else {
// otherwise, we can check to see if there is a matching credential parameter name that shadows an
// existing global credential id
parameterName = id;
}
final CredentialsParameterBinder binder = CredentialsParameterBinder.getOrCreate(run);
final CredentialsParameterBinding binding = binder.forParameterName(parameterName);
if (binding != null) {
isParameter = true;
inputUserId = binding.getUserId();
isDefaultValue = binding.isDefaultValue();
id = Util.fixNull(binding.getCredentialsId());
}
// non parameters or default parameter values can only come from the job's context
if (!isParameter || isDefaultValue) {
// 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.getDefaultAuthenticationOf(run.getParent());
List<C> candidates = new ArrayList<C>();
// we want the credentials available to the user the build is running as
candidates.addAll(
List<C> candidates = new ArrayList<>(
CredentialsProvider.lookupCredentials(type, run.getParent(), runAuth, domainRequirements)
);
// if that user can use the item's credentials, add those in too
if (runAuth != ACL.SYSTEM && run.getACL().hasPermission(runAuth, CredentialsProvider.USE_ITEM)) {
if (runAuth != ACL.SYSTEM && run.hasPermission(runAuth, CredentialsProvider.USE_ITEM)) {
candidates.addAll(
CredentialsProvider.lookupCredentials(type, run.getParent(), ACL.SYSTEM, domainRequirements)
);
Expand All @@ -911,15 +916,23 @@ public static <C extends IdCredentials> C findCredentialById(@NonNull String id,
// this is a parameter and not the default value, we need to determine who triggered the build
final Map.Entry<User, Run<?, ?>> triggeredBy = triggeredBy(run);
final Authentication a = triggeredBy == null ? Jenkins.ANONYMOUS : triggeredBy.getKey().impersonate();
List<C> candidates = new ArrayList<C>();
if (triggeredBy != null && run == triggeredBy.getValue()
&& run.getACL().hasPermission(a, CredentialsProvider.USE_OWN)) {
List<C> candidates = new ArrayList<>();
if (triggeredBy != null && run == triggeredBy.getValue() && run.hasPermission(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
// though, as there is no way to limit how far the passed-through parameters can be used
candidates.addAll(CredentialsProvider.lookupCredentials(type, run.getParent(), a, domainRequirements));
}
if (run.getACL().hasPermission(a, CredentialsProvider.USE_ITEM)) {
if (inputUserId != null) {
final User inputUser = User.getById(inputUserId, false);
if (inputUser != null) {
final Authentication inputAuth = inputUser.impersonate();
if (run.hasPermission(inputAuth, CredentialsProvider.USE_OWN)) {
candidates.addAll(CredentialsProvider.lookupCredentials(type, run.getParent(), inputAuth, domainRequirements));
}
}
}
if (run.hasPermission(a, CredentialsProvider.USE_ITEM)) {
// the triggering user is allowed to use the item's credentials, so add those into the list
// 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
Expand All @@ -930,7 +943,7 @@ public static <C extends IdCredentials> C findCredentialById(@NonNull String id,
CredentialsProvider.lookupCredentials(type, run.getParent(), runAuth, domainRequirements)
);
// if that user can use the item's credentials, add those in too
if (runAuth != ACL.SYSTEM && run.getACL().hasPermission(runAuth, CredentialsProvider.USE_ITEM)) {
if (runAuth != ACL.SYSTEM && run.hasPermission(runAuth, CredentialsProvider.USE_ITEM)) {
candidates.addAll(
CredentialsProvider.lookupCredentials(type, run.getParent(), ACL.SYSTEM, domainRequirements)
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/*
* The MIT License
*
* Copyright (c) 2019 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.builds;

import com.cloudbees.plugins.credentials.CredentialsParameterValue;
import hudson.model.Cause;
import hudson.model.InvisibleAction;
import hudson.model.ParameterValue;
import hudson.model.ParametersAction;
import hudson.model.Run;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.NoExternalUse;

import javax.annotation.CheckForNull;
import javax.annotation.Nonnull;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
* Tracks credentials being bound and unbound to a build. An instance is created and attached to a build when it is
* first looked up via {@link #getOrCreate(Run)}. This binds any existing {@link CredentialsParameterValue}s using the
* {@link hudson.model.Cause.UserIdCause} if available. Other plugins may
* {@linkplain #bindCredentialsParameter(String, CredentialsParameterValue) bind} and
* {@linkplain #unbindCredentialsParameter(String) unbind} parameters during a build.
*
* @since 2.3.0
*/
public final class CredentialsParameterBinder extends InvisibleAction {

/**
* Gets or creates a CredentialsParameterBinder for the given run.
* This automatically imports credentials parameters provided in a {@link ParametersAction}.
*/
@Nonnull
public static CredentialsParameterBinder getOrCreate(@Nonnull final Run<?, ?> run) {
CredentialsParameterBinder resolver = run.getAction(CredentialsParameterBinder.class);
if (resolver == null) {
resolver = new CredentialsParameterBinder();
final ParametersAction action = run.getAction(ParametersAction.class);
if (action != null) {
final Cause.UserIdCause cause = run.getCause(Cause.UserIdCause.class);
final String userId = cause == null ? null : cause.getUserId();
for (final ParameterValue parameterValue : action) {
if (parameterValue instanceof CredentialsParameterValue) {
resolver.bindCredentialsParameter(userId, (CredentialsParameterValue) parameterValue);
}
}
}
run.addAction(resolver);
}
return resolver;
}

private final Map<String, CredentialsParameterBinding> boundCredentials = new ConcurrentHashMap<>();

/**
* Binds a credentials parameter with an optional user ID. User credentials require a user ID.
*/
public void bindCredentialsParameter(@CheckForNull final String userId, @Nonnull final CredentialsParameterValue parameterValue) {
boundCredentials.put(parameterValue.getName(), CredentialsParameterBinding.fromParameter(userId, parameterValue));
}

/**
* Unbinds a credentials parameter.
*/
public void unbindCredentialsParameter(@Nonnull final String parameterName) {
boundCredentials.remove(parameterName);
}

@CheckForNull
@Restricted(NoExternalUse.class)
public CredentialsParameterBinding forParameterName(@Nonnull final String parameterName) {
return boundCredentials.get(parameterName);
}

boolean isEmpty() {
return boundCredentials.isEmpty();
}
}

0 comments on commit 43be901

Please sign in to comment.