/*
* The MIT License
*
* Copyright (c) 2011-2016, CloudBees, Inc., Stephen Connolly.
*
* 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 edu.umd.cs.findbugs.annotations.CheckForNull;
import edu.umd.cs.findbugs.annotations.NonNull;
import hudson.ExtensionList;
import hudson.Functions;
import hudson.RestrictedSince;
import hudson.model.Descriptor;
import hudson.model.Item;
import hudson.model.ModelObject;
import hudson.model.User;
import hudson.util.FormValidation;
import hudson.util.ListBoxModel;
import java.io.UnsupportedEncodingException;
import java.net.URI;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import jenkins.model.Jenkins;
import org.apache.commons.jelly.JellyContext;
import org.apache.commons.lang.StringUtils;
import org.jenkins.ui.icon.IconSpec;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.NoExternalUse;
import org.kohsuke.stapler.Ancestor;
import org.kohsuke.stapler.AncestorInPath;
import org.kohsuke.stapler.Stapler;
import org.kohsuke.stapler.StaplerRequest;
/**
* Descriptor for credentials.
*/
public abstract class CredentialsDescriptor extends Descriptor<Credentials> implements IconSpec {
private transient final Map<String, FormValidation.CheckMethod>
enhancedCheckMethods = new ConcurrentHashMap<String, FormValidation.CheckMethod>();
/**
* Constructor.
*
* @param clazz The concrete credentials class.
* @since 1.2
*/
protected CredentialsDescriptor(Class<? extends Credentials> clazz) {
super(clazz);
}
/**
* Infers the type of the corresponding {@link Credentials} from the outer class.
* This version works when you follow the common convention, where a descriptor
* is written as the static nested class of the describable class.
*
* @since 1.3
*/
protected CredentialsDescriptor() {
}
/**
* Fills in the scopes for a scope list-box.
*
* @return the scopes for the nearest request object that acts as a container for credentials.
*/
@SuppressWarnings("unused") // used by stapler
@Restricted(NoExternalUse.class)
@RestrictedSince("2.1.5")
public ListBoxModel doFillScopeItems(@ContextInPath ModelObject context) {
ListBoxModel m = new ListBoxModel();
Set<CredentialsScope> scopes = CredentialsProvider.lookupScopes(context);
if (scopes != null) {
for (CredentialsScope scope : scopes) {
m.add(scope.getDisplayName(), scope.toString());
}
}
return m;
}
/**
* Checks if asking for a credentials scope is relevant. For example, when a scope will be stored in
* {@link UserCredentialsProvider}, there is no need to specify the scope,
* as it can only be {@link CredentialsScope#USER}, but where the credential will be stored in
* {@link SystemCredentialsProvider}, there are multiple scopes relevant for that container, so the scope
* field is relevant.
*
* @return {@code true} if the nearest request object that acts as a container for credentials needs a scope
* to be specified.
*/
@SuppressWarnings("unused") // used by stapler
@Restricted(NoExternalUse.class)
@RestrictedSince("2.1.5")
public boolean isScopeRelevant() {
Ancestor ancestor = Stapler.getCurrentRequest().findAncestor(Object.class);
while (ancestor != null) {
if (ancestor.getObject() instanceof ModelObject) {
ModelObject context = unwrapContext((ModelObject) ancestor.getObject());
Set<CredentialsScope> scopes = CredentialsProvider.lookupScopes(context);
if (scopes != null) {
return scopes.size() > 1;
}
}
ancestor = ancestor.getPrev();
}
return false;
}
/**
* Similar to {@link #isScopeRelevant()} but operating on a specific {@link ModelObject} rather than trying to
* infer from the stapler request.
*
* @param object the object that is going to contain the credential.
* @return {@code true} if there is more than one {@link CredentialsScope} that can be used for the specified
* object.
*/
@SuppressWarnings("unused") // used by stapler
@Restricted(NoExternalUse.class)
@RestrictedSince("2.1.5")
public boolean isScopeRelevant(ModelObject object) {
Set<CredentialsScope> scopes = CredentialsProvider.lookupScopes(object);
return scopes != null && scopes.size() > 1;
}
/**
* Similar to {@link #isScopeRelevant()} but operating on a specific {@link CredentialsStore} rather than trying to
* infer from the stapler request.
*
* @param store the object that is going to contain the credential.
* @return {@code true} if there is more than one {@link CredentialsScope} that can be used for the specified
* object.
* @since 2.1.5
*/
@SuppressWarnings("unused") // used by stapler
@Restricted(NoExternalUse.class)
public boolean isScopeRelevant(@CheckForNull CredentialsStore store) {
Set<CredentialsScope> scopes = store == null ? null : store.getScopes();
return scopes != null && scopes.size() > 1;
}
/**
* Similar to {@link #isScopeRelevant()} but used by {@link CredentialsStoreAction}.
*
* @param wrapper the wrapper for the domain that is going to contain the credential.
* @return {@code true} if there is more than one {@link CredentialsScope} that can be used for the specified
* object.
*/
@SuppressWarnings("unused") // used by stapler
@Restricted(NoExternalUse.class)
@RestrictedSince("2.1.5")
public boolean isScopeRelevant(CredentialsStoreAction.DomainWrapper wrapper) {
if (wrapper != null) {
return isScopeRelevant(wrapper.getStore().getContext());
}
CredentialsStoreAction action =
Stapler.getCurrentRequest().findAncestorObject(CredentialsStoreAction.class);
if (action != null) {
return isScopeRelevant(action.getStore().getContext());
}
return isScopeRelevant();
}
/**
* Similar to {@link #isScopeRelevant()} but used by {@link CredentialsStoreAction}.
*
* @param wrapper the wrapper for the domain that is going to contain the credential.
* @return {@code true} if there is more than one {@link CredentialsScope} that can be used for the specified
* object.
*/
@SuppressWarnings("unused") // used by stapler
@Restricted(NoExternalUse.class)
@RestrictedSince("2.1.5")
public boolean isScopeRelevant(CredentialsStoreAction.CredentialsWrapper wrapper) {
if (wrapper != null) {
return isScopeRelevant(wrapper.getStore().getContext());
}
CredentialsStoreAction action =
Stapler.getCurrentRequest().findAncestorObject(CredentialsStoreAction.class);
if (action != null) {
return isScopeRelevant(action.getStore().getContext());
}
return isScopeRelevant();
}
/**
* Similar to {@link #isScopeRelevant()} but used by {@link CredentialsSelectHelper}.
*
* @param wrapper the wrapper for the domain that is going to contain the credential.
* @return {@code true} if there is more than one {@link CredentialsScope} that can be used for the specified
* object.
* @since 2.1.5
*/
@SuppressWarnings("unused") // used by stapler
@Restricted(NoExternalUse.class)
public boolean isScopeRelevant(CredentialsSelectHelper.WrappedCredentialsStore wrapper) {
if (wrapper != null) {
return isScopeRelevant(wrapper.getStore().getContext());
}
CredentialsStoreAction action =
Stapler.getCurrentRequest().findAncestorObject(CredentialsStoreAction.class);
if (action != null) {
return isScopeRelevant(action.getStore().getContext());
}
return isScopeRelevant();
}
/**
* Returns the config page for the credentials.
*
* @return the config page for the credentials.
*/
@SuppressWarnings("unused") // used by stapler
public String getCredentialsPage() {
return getViewPage(clazz, "credentials.jelly");
}
/**
* {@inheritDoc}
* @since 1.25
*/
public String getIconClassName() {
return "icon-credentials-credential";
}
/**
* Determines if this {@link CredentialsDescriptor} is applicable to the specified {@link CredentialsProvider}.
* <p>
* This method will be called by {@link CredentialsProvider#isApplicable(Descriptor)}
*
* @param provider the {@link CredentialsProvider} to check.
* @return {@code true} if this {@link CredentialsDescriptor} is applicable in the specified {@link CredentialsProvider}
* @since 2.0
*/
public boolean isApplicable(CredentialsProvider provider) {
return true;
}
/**
* In some cases the nearest {@link AncestorInPath} {@link ModelObject} is one of the Credentials plugins wrapper
* classes.
* This helper method unwraps those to return the correct context.
*
* @param context the context (wrapped or unwrapped).
* @return the unwrapped context.
* @since 2.1.5
*/
@NonNull
public static ModelObject unwrapContext(@NonNull ModelObject context) {
if (context instanceof CredentialsSelectHelper.WrappedCredentialsStore) {
return ((CredentialsSelectHelper.WrappedCredentialsStore) context).getStore().getContext();
}
if (context instanceof CredentialsStoreAction.CredentialsWrapper) {
return ((CredentialsStoreAction.CredentialsWrapper) context).getStore().getContext();
}
if (context instanceof CredentialsStoreAction.DomainWrapper) {
return ((CredentialsStoreAction.DomainWrapper) context).getStore().getContext();
}
return context;
}
/**
* Looks up the context given the provider and token.
* @param provider the provider.
* @param token the token.
* @return the context.
*
* @since 2.1.5
*/
@CheckForNull
public static ModelObject lookupContext(String provider, String token) {
for (CredentialsSelectHelper.ContextResolver r : ExtensionList
.lookup(CredentialsSelectHelper.ContextResolver.class)) {
if (r.getClass().getName().equals(provider)) {
return r.getContext(token);
}
}
return null;
}
/**
* Attempts to resolve the credentials context from the {@link Stapler#getCurrentRequest()} (includes special
* handling of the HTTP Referer to enable resolution from AJAX requests).
*
* @param type the type of context.
* @param <T> the type of context.
* @return the context from the request
* @since 2.1.5
*/
@CheckForNull
public static <T extends ModelObject> T findContextInPath(@NonNull Class<T> type) {
return findContextInPath(Stapler.getCurrentRequest(), type);
}
/**
* Attempts to resolve the credentials context from the {@link StaplerRequest} (includes special
* handling of the HTTP Referer to enable resolution from AJAX requests).
*
* @param request the {@link StaplerRequest}.
* @param type the type of context.
* @param <T> the type of context.
* @return the context from the request
* @since 2.1.5
*/
@CheckForNull
public static <T extends ModelObject> T findContextInPath(@NonNull StaplerRequest request, @NonNull Class<T> type) {
List<Ancestor> ancestors = request.getAncestors();
for (int i = ancestors.size() - 1; i >= 0; i--) {
Ancestor a = ancestors.get(i);
Object o = a.getObject();
// special case of unwrapping our internal wrapper classes.
if (o instanceof CredentialsSelectHelper.WrappedCredentialsStore) {
o = ((CredentialsSelectHelper.WrappedCredentialsStore) o).getStore().getContext();
} else if (o instanceof CredentialsStoreAction.CredentialsWrapper) {
o = ((CredentialsStoreAction.CredentialsWrapper) o).getStore().getContext();
} else if (o instanceof CredentialsStoreAction.DomainWrapper) {
o = ((CredentialsStoreAction.DomainWrapper) o).getStore().getContext();
} else if (o instanceof Descriptor && i == 1) { // URL is /descriptorByName/...
// TODO this is a https://issues.jenkins-ci.org/browse/JENKINS-19413 workaround
// we need to try an infer from the Referer as this is likely a doCheck or a doFill method
String referer = request.getReferer();
String rootPath = request.getRootPath();
if (referer != null && rootPath != null && referer.startsWith(rootPath)) {
// strip out any query portion of the referer URL.
String path = URI.create(referer.substring(rootPath.length())).getPath().substring(1);
// TODO have Stapler expose a method that can walk a path and produce the ancestors and use that
// what now follows is an example of a really evil hack, consequently this means...
//
// 7.. ,
// MMM. MMM.
// MMMMM .MMMMMM
// MMMM. MMMMM.
// OMMM MMZ
// MMM MM
// .MMMM $. . .MM,
// MMMMM MMM MM MMM
// .MMMMM. MMMMD 8MMM. MMMM
// MMMMMMM.MMMM MMMMM. MMMMMM
// MMMMMMMM.M . MMM. MMMMMMM
// MMMMMMMMM. MMMMMMMM
// MMMMMMMMM . .. MMMMMMMMMMM
// MMMMMMMM IMMMM Z.MMMMMMMMMM ,
// .MMMMMMM .M:M MMMMMMMMMMM M
// I MMMMMMM. MMMMMMMMMO M
// MMMM MMMMMMM .MMMMMMMMM. .
// :MMMMMM.MMMMMMM. MMMMMMMM .MMMM
// MMMMMMMMMMMMMMMM MMMMMMM MMMMMMMM
// MMMMMMMMM.MMMMMMM MMMMMMM MMMMMMMMMM.
// MMMMMMMMMMMMMMMM? MMMMMM MMMMMMMMMMM
// MMMMMMMMMM . . MMMMMIMMMMMMMMMMMM.
// MMMMMMMMMM .. :MMMMMMMMMMMM.
// DMMMMMMMMMM MMMMMMMMMMMMM.
// MMMMMMMMMM.M. MMMMMMMMMMMM.
// MMMMMM, ....
//
// I AM A SAD PANDA
List<String> pathSegments = new ArrayList<String>(Arrays.asList(StringUtils.split(path, "/")));
// strip out any leading junk
while (!pathSegments.isEmpty() && StringUtils.isBlank(pathSegments.get(0))) {
pathSegments.remove(0);
}
if (pathSegments.size() >= 2) {
String firstSegment = pathSegments.get(0);
if ("user".equals(firstSegment)) {
User user = User.get(pathSegments.get(1));
if (type.isInstance(user) && CredentialsProvider.hasStores(user)) {
// we have a winner
return type.cast(user);
}
} else if ("job".equals(firstSegment) || "item".equals(firstSegment) || "view"
.equals(firstSegment)) {
int index = 0;
while (index < pathSegments.size()) {
String segment = pathSegments.get(index);
if ("view".equals(segment)) {
// remove the /view/
pathSegments.remove(index);
if (index < pathSegments.size()) {
// remove the /view/{name}
pathSegments.remove(index);
}
} else if ("job".equals(segment) || "item".equals(segment)) {
// remove the /job/
pathSegments.remove(index);
// skip the name
index++;
} else {
// we have gone as far as we can parse the item path structure
while (index < pathSegments.size()) {
// remove the remainder
pathSegments.remove(index);
}
}
}
Jenkins jenkins = Jenkins.getActiveInstance();
while (!pathSegments.isEmpty()) {
String fullName = StringUtils.join(pathSegments, "/");
Item item = jenkins.getItemByFullName(fullName);
if (item != null) {
if (type.isInstance(item) && CredentialsProvider.hasStores(item)) {
// we have a winner
return type.cast(item);
}
}
// walk back up and try one level less deep
pathSegments.remove(pathSegments.size() - 1);
}
}
}
// ok we give up, we are not thirsty for more, we'll let "normal" ancestor in path logic continue
}
}
if (type.isInstance(o) && o instanceof ModelObject && CredentialsProvider.hasStores((ModelObject) o)) {
return type.cast(o);
}
}
return null;
}
/**
* {@inheritDoc}
*/
public FormValidation.CheckMethod getCheckMethod(String fieldName) {
// this is an ugly hack to make the @ContextInPath annotation more failsafe
// requires that you explicitly call out the checkUrl: checkUrl="${descriptor.getCheckUrl('fieldName')}"
FormValidation.CheckMethod method = enhancedCheckMethods.get(fieldName);
if (method == null) {
method = new EnhancedCheckMethod(this, fieldName);
enhancedCheckMethods.put(fieldName, method);
}
return method;
}
/**
* {@inheritDoc}
*/
@Override
public void calcFillSettings(String field, Map<String, Object> attributes) {
if (attributes.containsKey("fillUrl")) {
// the user already provided a custom one, get out of the way
super.calcFillSettings(field, attributes);
return;
}
// this is an ugly hack to make the @ContextInPath annotation more failsafe
super.calcFillSettings(field, attributes);
if (attributes.containsKey("fillUrl")) {
try {
JellyContext jelly = Functions.getCurrentJellyContext();
Object it = jelly.findVariable("it");
if (it instanceof CredentialsStore) {
ModelObject context = ((CredentialsStore) it).getContext();
for (CredentialsSelectHelper.ContextResolver r : ExtensionList
.lookup(CredentialsSelectHelper.ContextResolver.class)) {
String token = r.getToken(context);
if (token != null) {
String fillUrl = (String) attributes.get("fillUrl");
if (fillUrl != null) {
if (fillUrl.indexOf('?') != -1) {
fillUrl = fillUrl + '&';
} else {
fillUrl = fillUrl + '?';
}
attributes.put("fillUrl", fillUrl + "$provider=" +
URLEncoder.encode(r.getClass().getName(), "UTF-8")
+ "&$token=" + URLEncoder.encode(token, "UTF-8"));
}
}
}
}
} catch (AssertionError e) {
// ignore, we did the best we could
} catch (UnsupportedEncodingException e) {
// ignore, we did the best we could
}
}
}
/**
* {@inheritDoc}
*/
@Override
public void calcAutoCompleteSettings(String field, Map<String, Object> attributes) {
if (attributes.containsKey("autoCompleteUrl")) {
// the user already provided a custom one, get out of the way
super.calcAutoCompleteSettings(field, attributes);
return;
}
// this is an ugly hack to make the @ContextInPath annotation more failsafe
super.calcAutoCompleteSettings(field, attributes);
if (attributes.containsKey("autoCompleteUrl")) {
try {
JellyContext jelly = Functions.getCurrentJellyContext();
Object it = jelly.findVariable("it");
if (it instanceof CredentialsStore) {
ModelObject context = ((CredentialsStore) it).getContext();
for (CredentialsSelectHelper.ContextResolver r : ExtensionList
.lookup(CredentialsSelectHelper.ContextResolver.class)) {
String token = r.getToken(context);
if (token != null) {
String autoCompleteUrl = (String) attributes.get("autoCompleteUrl");
if (autoCompleteUrl != null) {
if (autoCompleteUrl.indexOf('?') != -1) {
autoCompleteUrl = autoCompleteUrl + '&';
} else {
autoCompleteUrl = autoCompleteUrl + '?';
}
attributes.put("autoCompleteUrl",
autoCompleteUrl + "$provider=" +
URLEncoder.encode(r.getClass().getName(), "UTF-8")
+ "&$token=" + URLEncoder.encode(token, "UTF-8"));
}
}
}
}
} catch (AssertionError e) {
// ignore, we did the best we could
} catch (UnsupportedEncodingException e) {
// ignore, we did the best we could
}
}
}
/**
* An enhanced {@link FormValidation.CheckMethod} that can add assistance for resolving the context from the
* request path.
*
* @since 2.1.5
*/
@Restricted(NoExternalUse.class)
public static class EnhancedCheckMethod extends FormValidation.CheckMethod {
/**
* {@inheritDoc}
*/
public EnhancedCheckMethod(Descriptor descriptor, String fieldName) {
super(descriptor, fieldName);
}
/**
* {@inheritDoc}
*/
@Override
public String toCheckUrl() {
String checkUrl = super.toCheckUrl();
if (checkUrl == null) {
return null;
}
try {
JellyContext jelly = Functions.getCurrentJellyContext();
Object it = jelly.findVariable("it");
if (it instanceof CredentialsStore) {
ModelObject context = ((CredentialsStore) it).getContext();
for (CredentialsSelectHelper.ContextResolver r : ExtensionList
.lookup(CredentialsSelectHelper.ContextResolver.class)) {
String token = r.getToken(context);
if (token != null) {
if (checkUrl.endsWith(".toString()")) {
checkUrl = StringUtils.removeEnd(checkUrl, ".toString()");
} else {
checkUrl = checkUrl + "+qs(this).addThis()";
}
return checkUrl
+ ".append('$provider=" +
Functions.jsStringEscape(URLEncoder.encode(r.getClass().getName(), "UTF-8"))
+ "')"
+ ".append('$token="
+ Functions.jsStringEscape(URLEncoder.encode(token, "UTF-8"))
+ "')"
+ ".toString()";
}
}
}
} catch (AssertionError e) {
// ignore, we did the best we could
} catch (UnsupportedEncodingException e) {
// ignore, we did the best we could
}
return checkUrl;
}
}
}