Merge "Add project scope feature to contributor-agreement enforcement."

This commit is contained in:
Patrick Hiesel
2018-10-18 07:14:41 +00:00
committed by Gerrit Code Review
8 changed files with 173 additions and 9 deletions

View File

@@ -25,13 +25,15 @@ Download the existing configuration from Gerrit:
---- ----
Contributor agreements are defined as contributor-agreement sections in Contributor agreements are defined as contributor-agreement sections in
`project.config`: `project.config` of `All-Projects`:
---- ----
[contributor-agreement "Individual"] [contributor-agreement "Individual"]
description = If you are going to be contributing code on your own, this is the one you want. You can sign this one online. description = If you are going to be contributing code on your own, this is the one you want. You can sign this one online.
agreementUrl = static/cla_individual.html agreementUrl = static/cla_individual.html
autoVerify = group CLA Accepted - Individual autoVerify = group CLA Accepted - Individual
accepted = group CLA Accepted - Individual accepted = group CLA Accepted - Individual
matchProjects = ^/.*$
excludeProjects = ^/not/my/project/
---- ----
Each `contributor-agreement` section within the `project.config` file must Each `contributor-agreement` section within the `project.config` file must
@@ -75,6 +77,16 @@ List of groups that will be considered when verifying that a
contributor agreement has been accepted. The groups' UUID must also contributor agreement has been accepted. The groups' UUID must also
appear in the `groups` file. appear in the `groups` file.
[[contributor-agreement.name.matchProjects]]contributor-agreement.<name>.matchProjects::
+
List of project regular expressions identifying projects where the
agreement is required. Defaults to every project when omitted.
[[contributor-agreement.name.excludeProjects]]contributor-agreement.<name>.excludeProjects::
+
List of project regular expressions identifying projects where the
agreement does not apply. Defaults to empty. i.e. no projects excluded.
GERRIT GERRIT
------ ------
Part of link:index.html[Gerrit Code Review] Part of link:index.html[Gerrit Code Review]

View File

@@ -1245,14 +1245,14 @@ public abstract class AbstractDaemonTest {
protected ContributorAgreement configureContributorAgreement(boolean autoVerify) protected ContributorAgreement configureContributorAgreement(boolean autoVerify)
throws Exception { throws Exception {
ContributorAgreement ca; ContributorAgreement ca;
String g = createGroup(autoVerify ? "cla-test-group" : "cla-test-no-auto-verify-group");
GroupApi groupApi = gApi.groups().id(g);
groupApi.description("CLA test group");
InternalGroup caGroup = group(new AccountGroup.UUID(groupApi.detail().id));
GroupReference groupRef = new GroupReference(caGroup.getGroupUUID(), caGroup.getName());
PermissionRule rule = new PermissionRule(groupRef);
rule.setAction(PermissionRule.Action.ALLOW);
if (autoVerify) { if (autoVerify) {
String g = createGroup("cla-test-group");
GroupApi groupApi = gApi.groups().id(g);
groupApi.description("CLA test group");
InternalGroup caGroup = group(new AccountGroup.UUID(groupApi.detail().id));
GroupReference groupRef = new GroupReference(caGroup.getGroupUUID(), caGroup.getName());
PermissionRule rule = new PermissionRule(groupRef);
rule.setAction(PermissionRule.Action.ALLOW);
ca = new ContributorAgreement("cla-test"); ca = new ContributorAgreement("cla-test");
ca.setAutoVerify(groupRef); ca.setAutoVerify(groupRef);
ca.setAccepted(ImmutableList.of(rule)); ca.setAccepted(ImmutableList.of(rule));
@@ -1261,6 +1261,8 @@ public abstract class AbstractDaemonTest {
} }
ca.setDescription("description"); ca.setDescription("description");
ca.setAgreementUrl("agreement-url"); ca.setAgreementUrl("agreement-url");
ca.setAccepted(ImmutableList.of(rule));
ca.setExcludeProjectsRegexes(ImmutableList.of("ExcludedProject"));
try (ProjectConfigUpdate u = updateProject(allProjects)) { try (ProjectConfigUpdate u = updateProject(allProjects)) {
u.getConfig().replace(ca); u.getConfig().replace(ca);

View File

@@ -25,6 +25,8 @@ public class ContributorAgreement implements Comparable<ContributorAgreement> {
protected List<PermissionRule> accepted; protected List<PermissionRule> accepted;
protected GroupReference autoVerify; protected GroupReference autoVerify;
protected String agreementUrl; protected String agreementUrl;
protected List<String> excludeProjectsRegexes;
protected List<String> matchProjectsRegexes;
protected ContributorAgreement() {} protected ContributorAgreement() {}
@@ -75,6 +77,28 @@ public class ContributorAgreement implements Comparable<ContributorAgreement> {
this.agreementUrl = agreementUrl; this.agreementUrl = agreementUrl;
} }
public List<String> getExcludeProjectsRegexes() {
if (excludeProjectsRegexes == null) {
excludeProjectsRegexes = new ArrayList<>();
}
return excludeProjectsRegexes;
}
public void setExcludeProjectsRegexes(List<String> excludeProjectsRegexes) {
this.excludeProjectsRegexes = excludeProjectsRegexes;
}
public List<String> getMatchProjectsRegexes() {
if (matchProjectsRegexes == null) {
matchProjectsRegexes = new ArrayList<>();
}
return matchProjectsRegexes;
}
public void setMatchProjectsRegexes(List<String> matchProjectsRegexes) {
this.matchProjectsRegexes = matchProjectsRegexes;
}
@Override @Override
public int compareTo(ContributorAgreement o) { public int compareTo(ContributorAgreement o) {
return getName().compareTo(o.getName()); return getName().compareTo(o.getName());

View File

@@ -14,6 +14,9 @@
package com.google.gerrit.server.project; package com.google.gerrit.server.project;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;
import com.google.gerrit.common.data.ContributorAgreement; import com.google.gerrit.common.data.ContributorAgreement;
import com.google.gerrit.common.data.PermissionRule; import com.google.gerrit.common.data.PermissionRule;
import com.google.gerrit.common.data.PermissionRule.Action; import com.google.gerrit.common.data.PermissionRule.Action;
@@ -34,6 +37,8 @@ import java.io.IOException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
@Singleton @Singleton
public class ContributorAgreementsChecker { public class ContributorAgreementsChecker {
@@ -93,6 +98,20 @@ public class ContributorAgreementsChecker {
List<AccountGroup.UUID> groupIds; List<AccountGroup.UUID> groupIds;
groupIds = okGroupIds; groupIds = okGroupIds;
// matchProjects defaults to match all projects when missing.
List<String> matchProjectsRegexes = ca.getMatchProjectsRegexes();
if (!matchProjectsRegexes.isEmpty()
&& !projectMatchesAnyPattern(project.get(), matchProjectsRegexes)) {
// Doesn't match, isn't checked.
continue;
}
// excludeProjects defaults to exclude no projects when missing.
List<String> excludeProjectsRegexes = ca.getExcludeProjectsRegexes();
if (!excludeProjectsRegexes.isEmpty()
&& projectMatchesAnyPattern(project.get(), excludeProjectsRegexes)) {
// Matches, isn't checked.
continue;
}
for (PermissionRule rule : ca.getAccepted()) { for (PermissionRule rule : ca.getAccepted()) {
if ((rule.getAction() == Action.ALLOW) if ((rule.getAction() == Action.ALLOW)
&& (rule.getGroup() != null) && (rule.getGroup() != null)
@@ -102,7 +121,7 @@ public class ContributorAgreementsChecker {
} }
} }
if (!iUser.getEffectiveGroups().containsAnyOf(okGroupIds)) { if (!okGroupIds.isEmpty() && !iUser.getEffectiveGroups().containsAnyOf(okGroupIds)) {
final StringBuilder msg = new StringBuilder(); final StringBuilder msg = new StringBuilder();
msg.append("No Contributor Agreement on file for user ") msg.append("No Contributor Agreement on file for user ")
.append(iUser.getNameEmail()) .append(iUser.getNameEmail())
@@ -114,4 +133,23 @@ public class ContributorAgreementsChecker {
throw new AuthException(msg.toString()); throw new AuthException(msg.toString());
} }
} }
private boolean projectMatchesAnyPattern(String projectName, List<String> regexes) {
checkNotNull(regexes);
checkArgument(!regexes.isEmpty());
for (String patternString : regexes) {
Pattern pattern;
try {
pattern = Pattern.compile(patternString);
} catch (PatternSyntaxException e) {
// Should never happen: Regular expressions validated when reading project.config.
throw new IllegalStateException(
"Invalid matchProjects or excludeProjects clause in project.config", e);
}
if (pattern.matcher(projectName).find()) {
return true;
}
}
return false;
}
} }

View File

@@ -127,6 +127,8 @@ public class ProjectConfig extends VersionedMetaData implements ValidationError.
private static final String KEY_ACCEPTED = "accepted"; private static final String KEY_ACCEPTED = "accepted";
private static final String KEY_AUTO_VERIFY = "autoVerify"; private static final String KEY_AUTO_VERIFY = "autoVerify";
private static final String KEY_AGREEMENT_URL = "agreementUrl"; private static final String KEY_AGREEMENT_URL = "agreementUrl";
private static final String KEY_MATCH_PROJECTS = "matchProjects";
private static final String KEY_EXCLUDE_PROJECTS = "excludeProjects";
private static final String NOTIFY = "notify"; private static final String NOTIFY = "notify";
private static final String KEY_EMAIL = "email"; private static final String KEY_EMAIL = "email";
@@ -593,6 +595,9 @@ public class ProjectConfig extends VersionedMetaData implements ValidationError.
ca.setAgreementUrl(rc.getString(CONTRIBUTOR_AGREEMENT, name, KEY_AGREEMENT_URL)); ca.setAgreementUrl(rc.getString(CONTRIBUTOR_AGREEMENT, name, KEY_AGREEMENT_URL));
ca.setAccepted( ca.setAccepted(
loadPermissionRules(rc, CONTRIBUTOR_AGREEMENT, name, KEY_ACCEPTED, groupsByName, false)); loadPermissionRules(rc, CONTRIBUTOR_AGREEMENT, name, KEY_ACCEPTED, groupsByName, false));
ca.setExcludeProjectsRegexes(
loadPatterns(rc, CONTRIBUTOR_AGREEMENT, name, KEY_EXCLUDE_PROJECTS));
ca.setMatchProjectsRegexes(loadPatterns(rc, CONTRIBUTOR_AGREEMENT, name, KEY_MATCH_PROJECTS));
List<PermissionRule> rules = List<PermissionRule> rules =
loadPermissionRules( loadPermissionRules(
@@ -753,6 +758,22 @@ public class ProjectConfig extends VersionedMetaData implements ValidationError.
} }
} }
private ImmutableList<String> loadPatterns(
Config rc, String section, String subsection, String varName) {
ImmutableList.Builder<String> patterns = ImmutableList.builder();
for (String patternString : rc.getStringList(section, subsection, varName)) {
try {
// While one could just use getStringList directly, compiling first will cause the server
// to fail fast if any of the patterns are invalid.
patterns.add(Pattern.compile(patternString).pattern());
} catch (PatternSyntaxException e) {
error(new ValidationError(PROJECT_CONFIG, "Invalid regular expression: " + e.getMessage()));
continue;
}
}
return patterns.build();
}
private ImmutableList<PermissionRule> loadPermissionRules( private ImmutableList<PermissionRule> loadPermissionRules(
Config rc, Config rc,
String section, String section,
@@ -1163,6 +1184,16 @@ public class ProjectConfig extends VersionedMetaData implements ValidationError.
ca.getName(), ca.getName(),
KEY_ACCEPTED, KEY_ACCEPTED,
ruleToStringList(ca.getAccepted(), keepGroups)); ruleToStringList(ca.getAccepted(), keepGroups));
rc.setStringList(
CONTRIBUTOR_AGREEMENT,
ca.getName(),
KEY_EXCLUDE_PROJECTS,
patternToStringList(ca.getExcludeProjectsRegexes()));
rc.setStringList(
CONTRIBUTOR_AGREEMENT,
ca.getName(),
KEY_MATCH_PROJECTS,
patternToStringList(ca.getMatchProjectsRegexes()));
} }
} }
@@ -1206,6 +1237,10 @@ public class ProjectConfig extends VersionedMetaData implements ValidationError.
} }
} }
private List<String> patternToStringList(List<String> list) {
return list;
}
private List<String> ruleToStringList( private List<String> ruleToStringList(
List<PermissionRule> list, Set<AccountGroup.UUID> keepGroups) { List<PermissionRule> list, Set<AccountGroup.UUID> keepGroups) {
List<String> rules = new ArrayList<>(); List<String> rules = new ArrayList<>();

View File

@@ -181,6 +181,28 @@ public class AgreementsIT extends AbstractDaemonTest {
gApi.changes().id(change.changeId).revert(); gApi.changes().id(change.changeId).revert();
} }
@Test
public void revertExcludedProjectChangeWithoutCLA() throws Exception {
// Contributor agreements configured with excludeProjects = ExcludedProject
// in AbstractDaemonTest.configureContributorAgreement(...)
assume().that(isContributorAgreementsEnabled()).isTrue();
// Create a change succeeds when agreement is not required
setUseContributorAgreements(InheritableBoolean.FALSE);
// Project name includes test method name which contains ExcludedProject
ChangeInfo change = gApi.changes().create(newChangeInput()).get();
// Approve and submit it
setApiUser(admin);
gApi.changes().id(change.changeId).current().review(ReviewInput.approve());
gApi.changes().id(change.changeId).current().submit(new SubmitInput());
// Revert in excluded project is allowed even when CLA is required but not signed
setApiUser(user);
setUseContributorAgreements(InheritableBoolean.TRUE);
gApi.changes().id(change.changeId).revert();
}
@Test @Test
public void cherrypickChangeWithoutCLA() throws Exception { public void cherrypickChangeWithoutCLA() throws Exception {
assume().that(isContributorAgreementsEnabled()).isTrue(); assume().that(isContributorAgreementsEnabled()).isTrue();
@@ -240,6 +262,17 @@ public class AgreementsIT extends AbstractDaemonTest {
gApi.changes().create(newChangeInput()); gApi.changes().create(newChangeInput());
} }
@Test
public void createExcludedProjectChangeIgnoresCLA() throws Exception {
// Contributor agreements configured with excludeProjects = ExcludedProject
// in AbstractDaemonTest.configureContributorAgreement(...)
assume().that(isContributorAgreementsEnabled()).isTrue();
// Create a change in excluded project is allowed even when CLA is required but not signed.
setUseContributorAgreements(InheritableBoolean.TRUE);
gApi.changes().create(newChangeInput());
}
private void assertAgreement(AgreementInfo info, ContributorAgreement ca) { private void assertAgreement(AgreementInfo info, ContributorAgreement ca) {
assertThat(info.name).isEqualTo(ca.getName()); assertThat(info.name).isEqualTo(ca.getName());
assertThat(info.description).isEqualTo(ca.getDescription()); assertThat(info.description).isEqualTo(ca.getDescription());

View File

@@ -239,6 +239,7 @@ public class ChangeEditIT extends AbstractDaemonTest {
@Test @Test
public void publishEditRestWithoutCLA() throws Exception { public void publishEditRestWithoutCLA() throws Exception {
configureContributorAgreement(true);
createArbitraryEditFor(changeId); createArbitraryEditFor(changeId);
setUseContributorAgreements(InheritableBoolean.TRUE); setUseContributorAgreements(InheritableBoolean.TRUE);
adminRestSession.post(urlPublish(changeId)).assertForbidden(); adminRestSession.post(urlPublish(changeId)).assertForbidden();

View File

@@ -104,6 +104,13 @@ public class ProjectConfigTest extends GerritBaseTests {
+ " sameGroupVisibility = block group Staff\n" + " sameGroupVisibility = block group Staff\n"
+ "[contributor-agreement \"Individual\"]\n" + "[contributor-agreement \"Individual\"]\n"
+ " description = A simple description\n" + " description = A simple description\n"
+ " matchProjects = ^/ourproject\n"
+ " matchProjects = ^/ourotherproject\n"
+ " matchProjects = ^/someotherroot/ourproject\n"
+ " excludeProjects = ^/theirproject\n"
+ " excludeProjects = ^/theirotherproject\n"
+ " excludeProjects = ^/someotherroot/theirproject\n"
+ " excludeProjects = ^/someotherroot/theirotherproject\n"
+ " accepted = group Developers\n" + " accepted = group Developers\n"
+ " accepted = group Staff\n" + " accepted = group Staff\n"
+ " autoVerify = group Developers\n" + " autoVerify = group Developers\n"
@@ -115,6 +122,14 @@ public class ProjectConfigTest extends GerritBaseTests {
ContributorAgreement ca = cfg.getContributorAgreement("Individual"); ContributorAgreement ca = cfg.getContributorAgreement("Individual");
assertThat(ca.getName()).isEqualTo("Individual"); assertThat(ca.getName()).isEqualTo("Individual");
assertThat(ca.getDescription()).isEqualTo("A simple description"); assertThat(ca.getDescription()).isEqualTo("A simple description");
assertThat(ca.getMatchProjectsRegexes())
.containsExactly("^/ourproject", "^/ourotherproject", "^/someotherroot/ourproject");
assertThat(ca.getExcludeProjectsRegexes())
.containsExactly(
"^/theirproject",
"^/theirotherproject",
"^/someotherroot/theirproject",
"^/someotherroot/theirotherproject");
assertThat(ca.getAgreementUrl()).isEqualTo("http://www.example.com/agree"); assertThat(ca.getAgreementUrl()).isEqualTo("http://www.example.com/agree");
assertThat(ca.getAccepted()).hasSize(2); assertThat(ca.getAccepted()).hasSize(2);
assertThat(ca.getAccepted().get(0).getGroup()).isEqualTo(developers); assertThat(ca.getAccepted().get(0).getGroup()).isEqualTo(developers);
@@ -256,6 +271,7 @@ public class ProjectConfigTest extends GerritBaseTests {
+ " sameGroupVisibility = block group Staff\n" + " sameGroupVisibility = block group Staff\n"
+ "[contributor-agreement \"Individual\"]\n" + "[contributor-agreement \"Individual\"]\n"
+ " description = A simple description\n" + " description = A simple description\n"
+ " matchProjects = ^/ourproject\n"
+ " accepted = group Developers\n" + " accepted = group Developers\n"
+ " autoVerify = group Developers\n" + " autoVerify = group Developers\n"
+ " agreementUrl = http://www.example.com/agree\n" + " agreementUrl = http://www.example.com/agree\n"
@@ -273,6 +289,8 @@ public class ProjectConfigTest extends GerritBaseTests {
ContributorAgreement ca = cfg.getContributorAgreement("Individual"); ContributorAgreement ca = cfg.getContributorAgreement("Individual");
ca.setAccepted(Collections.singletonList(new PermissionRule(cfg.resolve(staff)))); ca.setAccepted(Collections.singletonList(new PermissionRule(cfg.resolve(staff))));
ca.setAutoVerify(null); ca.setAutoVerify(null);
ca.setMatchProjectsRegexes(null);
ca.setExcludeProjectsRegexes(Collections.singletonList("^/theirproject"));
ca.setDescription("A new description"); ca.setDescription("A new description");
rev = commit(cfg); rev = commit(cfg);
assertThat(text(rev, "project.config")) assertThat(text(rev, "project.config"))
@@ -289,6 +307,7 @@ public class ProjectConfigTest extends GerritBaseTests {
+ " description = A new description\n" + " description = A new description\n"
+ " accepted = group Staff\n" + " accepted = group Staff\n"
+ " agreementUrl = http://www.example.com/agree\n" + " agreementUrl = http://www.example.com/agree\n"
+ "\texcludeProjects = ^/theirproject\n"
+ "[label \"CustomLabel\"]\n" + "[label \"CustomLabel\"]\n"
+ LABEL_SCORES_CONFIG + LABEL_SCORES_CONFIG
+ "\tfunction = MaxWithBlock\n" // label gets this function when it is created + "\tfunction = MaxWithBlock\n" // label gets this function when it is created