Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,29 @@ node {

![Sidebar](docs/images/deploy-action.png)

#### Restricting who is shown a link

A link can be narrowed to particular people with the optional `visibleTo`:

```groovy
buildAddUrl(title: 'Deploy to PROD', url: '/job/deploy-prod/parambuild/?version=1.2.3',
visibleTo: [users: ['alice'], groups: ['release-managers']])
```

Listing neither users nor groups means no narrowing, and the link behaves as it
always has. When something is listed, the link is hidden from everyone else in
the sidebar and the details bar, **and its URL answers 404** — hiding an icon
is not access control, since the target is in the page source and the link's
path is derivable.

This only narrows; it cannot widen. Jenkins has already decided who may read
the build before any of this runs.

**It is not the security boundary for deploying.** The job the link points at
enforces its own permissions -- a `parambuild` URL still requires `Item.BUILD`
on that job. `visibleTo` decides who is shown a shortcut, not who may use what
it points at.

#### Notes on modern Jenkins (plugin 0.2.0+)

Since 0.2.0 the button no longer exposes the raw target URL as the action URL.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
import hudson.FilePath;
import hudson.Launcher;
import hudson.Util;
import hudson.model.AbstractDescribableImpl;
import hudson.model.AbstractProject;
import hudson.model.Action;
import hudson.model.Descriptor;
import hudson.model.Run;
import hudson.model.TaskListener;
import hudson.tasks.BuildStepDescriptor;
Expand All @@ -16,18 +18,26 @@
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import jenkins.model.Jenkins;
import jenkins.tasks.SimpleBuildStep;
import org.jenkins.ui.icon.IconSpec;
import org.jenkinsci.Symbol;
import org.kohsuke.stapler.DataBoundConstructor;
import org.kohsuke.stapler.DataBoundSetter;
import org.kohsuke.stapler.StaplerRequest2;
import org.kohsuke.stapler.StaplerResponse2;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;

public class BuildAddUrl extends Builder implements SimpleBuildStep {

private final String title;
private final String url;
private VisibleTo visibleTo;

@DataBoundConstructor
public BuildAddUrl(String title, String url) {
Expand All @@ -43,6 +53,15 @@ public String getUrl() {
return url;
}

public VisibleTo getVisibleTo() {
return visibleTo;
}

@DataBoundSetter
public void setVisibleTo(VisibleTo visibleTo) {
this.visibleTo = visibleTo;
}

@Override
public BuildStepMonitor getRequiredMonitorService() {
return BuildStepMonitor.NONE;
Expand All @@ -56,7 +75,97 @@ public void perform(
@NonNull Launcher launcher,
@NonNull TaskListener listener)
throws InterruptedException, IOException {
run.addAction(new BuildUrlAction(title, url));
run.addAction(new BuildUrlAction(title, url, visibleTo));
}

/**
* Optional narrowing of who is shown a link, by user id or group name.
*
* <p>Deliberately only principal matching on top of what the authorization
* strategy has already granted. The upstream proposal hand-rolled a
* membership model that ignored the strategy entirely, which both hid the
* link from administrators who were not listed and showed it to listed
* users with no permission on the target.
*/
public static class VisibleTo extends AbstractDescribableImpl<VisibleTo> {
private List<String> users;
private List<String> groups;

@DataBoundConstructor
public VisibleTo(List<String> users, List<String> groups) {
this.users = clean(users);
this.groups = clean(groups);
}

private static List<String> clean(List<String> raw) {
if (raw == null) {
return Collections.emptyList();
}
List<String> out = new ArrayList<>(raw.size());
for (String entry : raw) {
String trimmed = Util.fixEmptyAndTrim(entry);
if (trimmed != null) {
out.add(trimmed);
}
}
return Collections.unmodifiableList(out);
}

private static List<String> split(String text) {
if (Util.fixEmptyAndTrim(text) == null) {
return Collections.emptyList();
}
return clean(java.util.Arrays.asList(text.split("[,\r\n]")));
}

public List<String> getUsers() {
return users;
}

public List<String> getGroups() {
return groups;
}

/*
* The form binds through these rather than through the lists directly.
* A textarea submits one string, and binding that straight onto a
* List<String> stores the list's own toString as a single element: a
* round trip of ["alice"] came back as ["[alice]"]. The form rendered
* perfectly well while quietly corrupting what was typed into it, which
* a round-trip test is the only thing that shows.
*/
public String getUsersText() {
return String.join("\n", users);
}

@DataBoundSetter
public void setUsersText(String usersText) {
this.users = split(usersText);
}

public String getGroupsText() {
return String.join("\n", groups);
}

@DataBoundSetter
public void setGroupsText(String groupsText) {
this.groups = split(groupsText);
}

/** No principals listed means no narrowing, not "nobody". */
public boolean isEmpty() {
return users.isEmpty() && groups.isEmpty();
}

@Extension
@Symbol("visibleTo")
public static class DescriptorImpl extends Descriptor<VisibleTo> {
@Override
@NonNull
public String getDisplayName() {
return "Visible to";
}
}
}

@Extension
Expand Down Expand Up @@ -88,22 +197,70 @@ public boolean isApplicable(Class<? extends AbstractProject> t) {
public static class BuildUrlAction implements Action, IconSpec {
private final String title;
private final String url;
private final VisibleTo visibleTo;

BuildUrlAction(String title, String url) {
this(title, url, null);
}

BuildUrlAction(String title, String url, VisibleTo visibleTo) {
this.title = title;
this.url = url;
this.visibleTo = visibleTo;
}

public VisibleTo getVisibleTo() {
return visibleTo;
}

/**
* Whether this link exists at all for the given caller.
*
* <p>An unset {@code visibleTo} means everyone who can read the build,
* which is what Jenkins has already decided by the time anything here
* runs. When it is set, this narrows that further; it never widens it,
* so it cannot grant anyone access the authorization strategy withheld.
*
* <p>It is also not the security boundary for deploying. The target of
* the link enforces its own permissions -- a {@code parambuild} URL
* still requires Item.BUILD on the job it points at. This decides who
* is shown a shortcut.
*/
public boolean isVisibleTo(Authentication authentication) {
if (visibleTo == null || visibleTo.isEmpty()) {
return true;
}
if (authentication == null) {
return false;
}
if (visibleTo.getUsers().contains(authentication.getName())) {
return true;
}
for (GrantedAuthority authority : authentication.getAuthorities()) {
if (authority.getAuthority() != null && visibleTo.getGroups().contains(authority.getAuthority())) {
return true;
}
}
return false;
}

public boolean isVisible() {
return isVisibleTo(Jenkins.getAuthentication2());
}

@Override
public String getIconFileName() {
if (!isVisible()) {
return null;
}
// Hardcoded artifact id: getClass().getPackage().getImplementationTitle()
// is unreliable under modern plugin classloaders (may return null).
return "/plugin/deploy-dashboard/deploy.png";
}

@Override
public String getIconClassName() {
return "symbol-rocket-outline plugin-ionicons-api";
return isVisible() ? "symbol-rocket-outline plugin-ionicons-api" : null;
}

@Override
Expand Down Expand Up @@ -149,10 +306,15 @@ boolean isSafeUrl() {
* sidebar and the details bar), it has no side effects, and it is only
* reachable through the run's URL, which Jenkins already gates on
* Item.READ while resolving the job and the build.
*
* <p>It does check {@code visibleTo}. Hiding the icon is not access
* control -- the URL is in the page source, and this endpoint has a
* derivable path -- so a link someone is not meant to see answers 404
* here rather than merely being absent from their sidebar.
*/
// lgtm[jenkins/csrf] lgtm[jenkins/no-permission-check]
// lgtm[jenkins/csrf]
public void doIndex(StaplerRequest2 req, StaplerResponse2 rsp) throws IOException {
if (!isSafeUrl()) {
if (!isSafeUrl() || !isVisible()) {
rsp.sendError(StaplerResponse2.SC_NOT_FOUND);
return;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ public Class<Run> type() {
@NonNull
public List<? extends Detail> createFor(@NonNull Run target) {
return target.getActions(BuildUrlAction.class).stream()
// The details bar is the third place a link surfaces, next to
// the classic sidebar and the redirect endpoint itself. All
// three have to agree, or "hidden" means only "hidden in one
// of the places you might look".
.filter(BuildUrlAction::isVisible)
.map(action -> new BuildUrlDetail(target, action))
.collect(Collectors.toList());
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?jelly escape-by-default='true'?>
<!--
Leaving both lists empty means no narrowing at all, not "nobody". The link
stays visible to everyone who can already read the build.
-->
<j:jelly xmlns:j="jelly:core" xmlns:f="/lib/form">
<f:entry title="Users" field="usersText"
description="User ids, one per line or comma separated. Leave empty to not narrow by user.">
<f:textarea/>
</f:entry>
<f:entry title="Groups" field="groupsText"
description="Group or role names as the security realm reports them, one per line or comma separated.">
<f:textarea/>
</f:entry>
</j:jelly>
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?jelly escape-by-default='true'?>
<!--
The step had no form, so freestyle configuration and the Snippet Generator
both rendered an empty panel.
-->
<j:jelly xmlns:j="jelly:core" xmlns:f="/lib/form">
<f:entry title="Title" field="title">
<f:textbox/>
</f:entry>
<f:entry title="URL" field="url">
<f:textbox/>
</f:entry>
<f:optionalProperty title="Restrict who is shown this link" field="visibleTo"/>
</j:jelly>
Loading
Loading