blob: 8ddf5850d05dea86645d402a88f38202e64f12d4 [file] [log] [blame]
// Copyright (C) 2010 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.gerrit.server.project;
import static com.google.gerrit.reviewdb.ApprovalCategory.FORGE_AUTHOR;
import static com.google.gerrit.reviewdb.ApprovalCategory.FORGE_COMMITTER;
import static com.google.gerrit.reviewdb.ApprovalCategory.FORGE_IDENTITY;
import static com.google.gerrit.reviewdb.ApprovalCategory.FORGE_SERVER;
import static com.google.gerrit.reviewdb.ApprovalCategory.OWN;
import static com.google.gerrit.reviewdb.ApprovalCategory.PUSH_HEAD;
import static com.google.gerrit.reviewdb.ApprovalCategory.PUSH_HEAD_CREATE;
import static com.google.gerrit.reviewdb.ApprovalCategory.PUSH_HEAD_REPLACE;
import static com.google.gerrit.reviewdb.ApprovalCategory.PUSH_HEAD_UPDATE;
import static com.google.gerrit.reviewdb.ApprovalCategory.PUSH_TAG;
import static com.google.gerrit.reviewdb.ApprovalCategory.PUSH_TAG_ANNOTATED;
import static com.google.gerrit.reviewdb.ApprovalCategory.PUSH_TAG_SIGNED;
import static com.google.gerrit.reviewdb.ApprovalCategory.READ;
import com.google.gerrit.common.data.ParamertizedString;
import com.google.gerrit.reviewdb.AccountGroup;
import com.google.gerrit.reviewdb.ApprovalCategory;
import com.google.gerrit.reviewdb.RefRight;
import com.google.gerrit.reviewdb.SystemConfig;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.IdentifiedUser;
import com.google.inject.Inject;
import com.google.inject.assistedinject.Assisted;
import dk.brics.automaton.RegExp;
import org.apache.commons.lang.StringUtils;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevObject;
import org.eclipse.jgit.revwalk.RevTag;
import org.eclipse.jgit.revwalk.RevWalk;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.SortedMap;
import java.util.TreeMap;
import java.util.regex.Pattern;
/** Manages access control for Git references (aka branches, tags). */
public class RefControl {
public interface Factory {
RefControl create(ProjectControl projectControl, String ref);
}
private final SystemConfig systemConfig;
private final ProjectControl projectControl;
private final String refName;
private Boolean canForgeAuthor;
private Boolean canForgeCommitter;
@Inject
protected RefControl(final SystemConfig systemConfig,
@Assisted final ProjectControl projectControl,
@Assisted String ref) {
this.systemConfig = systemConfig;
if (isRE(ref)) {
ref = shortestExample(ref);
} else if (ref.endsWith("/*")) {
ref = ref.substring(0, ref.length() - 1);
}
this.projectControl = projectControl;
this.refName = ref;
}
public String getRefName() {
return refName;
}
public ProjectControl getProjectControl() {
return projectControl;
}
public CurrentUser getCurrentUser() {
return getProjectControl().getCurrentUser();
}
public RefControl forAnonymousUser() {
return getProjectControl().forAnonymousUser().controlForRef(getRefName());
}
public RefControl forUser(final CurrentUser who) {
return getProjectControl().forUser(who).controlForRef(getRefName());
}
/** Is this user a ref owner? */
public boolean isOwner() {
if (canPerform(OWN, (short) 1)) {
return true;
}
// We have to prevent infinite recursion here, the project control
// calls us to find out if there is ownership of all references in
// order to determine project level ownership.
//
if (getRefName().equals(
RefRight.ALL.substring(0, RefRight.ALL.length() - 1))) {
return getCurrentUser().isAdministrator();
} else {
return getProjectControl().isOwner();
}
}
/** Can this user see this reference exists? */
public boolean isVisible() {
return getProjectControl().visibleForReplication()
|| canPerform(READ, (short) 1);
}
/**
* Determines whether the user can upload a change to the ref controlled by
* this object.
*
* @return {@code true} if the user specified can upload a change to the Git
* ref
*/
public boolean canUpload() {
return canPerform(READ, (short) 2);
}
/** @return true if this user can submit merge patch sets to this ref */
public boolean canUploadMerges() {
return canPerform(READ, (short) 3);
}
/** @return true if this user can submit patch sets to this ref */
public boolean canSubmit() {
return canPerform(ApprovalCategory.SUBMIT, (short) 1);
}
/** @return true if the user can update the reference as a fast-forward. */
public boolean canUpdate() {
return canPerform(PUSH_HEAD, PUSH_HEAD_UPDATE);
}
/** @return true if the user can rewind (force push) the reference. */
public boolean canForceUpdate() {
return canPerform(PUSH_HEAD, PUSH_HEAD_REPLACE) || canDelete();
}
/**
* Determines whether the user can create a new Git ref.
*
* @param rw revision pool {@code object} was parsed in.
* @param object the object the user will start the reference with.
* @return {@code true} if the user specified can create a new Git ref
*/
public boolean canCreate(RevWalk rw, RevObject object) {
boolean owner;
switch (getCurrentUser().getAccessPath()) {
case WEB_UI:
owner = isOwner();
break;
default:
owner = false;
}
if (object instanceof RevCommit) {
return owner || canPerform(PUSH_HEAD, PUSH_HEAD_CREATE);
} else if (object instanceof RevTag) {
final RevTag tag = (RevTag) object;
try {
rw.parseBody(tag);
} catch (IOException e) {
return false;
}
// If tagger is present, require it matches the user's email.
//
final PersonIdent tagger = tag.getTaggerIdent();
if (tagger != null) {
boolean valid;
if (getCurrentUser() instanceof IdentifiedUser) {
final IdentifiedUser user = (IdentifiedUser) getCurrentUser();
final String addr = tagger.getEmailAddress();
valid = user.getEmailAddresses().contains(addr);
} else {
valid = false;
}
if (!valid && !owner && !canPerform(FORGE_IDENTITY, FORGE_COMMITTER)) {
return false;
}
}
// If the tag has a PGP signature, allow a lower level of permission
// than if it doesn't have a PGP signature.
//
if (tag.getFullMessage().contains("-----BEGIN PGP SIGNATURE-----\n")) {
return owner || canPerform(PUSH_TAG, PUSH_TAG_SIGNED);
} else {
return owner || canPerform(PUSH_TAG, PUSH_TAG_ANNOTATED);
}
} else {
return false;
}
}
/**
* Determines whether the user can delete the Git ref controlled by this
* object.
*
* @return {@code true} if the user specified can delete a Git ref.
*/
public boolean canDelete() {
switch (getCurrentUser().getAccessPath()) {
case WEB_UI:
return isOwner() || canPerform(PUSH_HEAD, PUSH_HEAD_REPLACE);
case GIT:
return canPerform(PUSH_HEAD, PUSH_HEAD_REPLACE);
default:
return false;
}
}
/** @return true if this user can forge the author line in a commit. */
public boolean canForgeAuthor() {
if (canForgeAuthor == null) {
canForgeAuthor = canPerform(FORGE_IDENTITY, FORGE_AUTHOR);
}
return canForgeAuthor;
}
/** @return true if this user can forge the committer line in a commit. */
public boolean canForgeCommitter() {
if (canForgeCommitter == null) {
canForgeCommitter = canPerform(FORGE_IDENTITY, FORGE_COMMITTER);
}
return canForgeCommitter;
}
/** @return true if this user can forge the server on the committer line. */
public boolean canForgeGerritServerIdentity() {
return canPerform(FORGE_IDENTITY, FORGE_SERVER);
}
public short normalize(ApprovalCategory.Id category, short score) {
short minAllowed = 0, maxAllowed = 0;
for (RefRight r : getApplicableRights(category)) {
if (getCurrentUser().getEffectiveGroups().contains(r.getAccountGroupId())) {
minAllowed = (short) Math.min(minAllowed, r.getMinValue());
maxAllowed = (short) Math.max(maxAllowed, r.getMaxValue());
}
}
if (score < minAllowed) {
score = minAllowed;
}
if (score > maxAllowed) {
score = maxAllowed;
}
return score;
}
/**
* Convenience holder class used to map a ref pattern to the list of
* {@code RefRight}s that use it in the database.
*/
public final static class RefRightsForPattern {
private final List<RefRight> rights;
private boolean containsExclusive;
public RefRightsForPattern() {
rights = new ArrayList<RefRight>();
containsExclusive = false;
}
public void addRight(RefRight right) {
rights.add(right);
if (right.isExclusive()) {
containsExclusive = true;
}
}
public List<RefRight> getRights() {
return Collections.unmodifiableList(rights);
}
public boolean containsExclusive() {
return containsExclusive;
}
/**
* Returns The max allowed value for this ref pattern for all specified
* groups.
*
* @param groups The groups of the user
* @return The allowed value for this ref for all the specified groups
*/
private boolean allowedValueForRef(Set<AccountGroup.Id> groups, short level) {
for (RefRight right : rights) {
if (groups.contains(right.getAccountGroupId())
&& right.getMaxValue() >= level) {
return true;
}
}
return false;
}
}
boolean canPerform(ApprovalCategory.Id actionId, short level) {
final Set<AccountGroup.Id> groups = getCurrentUser().getEffectiveGroups();
List<RefRight> allRights = new ArrayList<RefRight>();
allRights.addAll(getAllRights(actionId));
SortedMap<String, RefRightsForPattern> perPatternRights =
sortedRightsByPattern(allRights);
for (RefRightsForPattern right : perPatternRights.values()) {
if (right.allowedValueForRef(groups, level)) {
return true;
}
if (right.containsExclusive() && !actionId.equals(OWN)) {
break;
}
}
return false;
}
/**
* Order the Ref Pattern by the most specific. This sort is done by:
* <ul>
* <li>1 - The minor value of Levenshtein string distance between the branch
* name and the regex string shortest example. A shorter distance is a more
* specific match.
* <li>2 - Finites first, infinities after.
* <li>3 - Number of transitions.
* <li>4 - Length of the expression text.
* </ul>
*
* Levenshtein distance is a measure of the similarity between two strings.
* The distance is the number of deletions, insertions, or substitutions
* required to transform one string into another.
*
* For example, if given refs/heads/m* and refs/heads/*, the distances are 5
* and 6. It means that refs/heads/m* is more specific because it's closer to
* refs/heads/master than refs/heads/*.
*
* Another example could be refs/heads/* and refs/heads/[a-zA-Z]*, the
* distances are both 6. Both are infinite, but refs/heads/[a-zA-Z]* has more
* transitions, which after all turns it more specific.
*/
private final Comparator<String> BY_MOST_SPECIFIC_SORT =
new Comparator<String>() {
public int compare(final String pattern1, final String pattern2) {
int cmp = distance(pattern1) - distance(pattern2);
if (cmp == 0) {
boolean p1_finite = finite(pattern1);
boolean p2_finite = finite(pattern2);
if (p1_finite && !p2_finite) {
cmp = -1;
} else if (!p1_finite && p2_finite) {
cmp = 1;
} else /* if (f1 == f2) */{
cmp = 0;
}
}
if (cmp == 0) {
cmp = transitions(pattern1) - transitions(pattern2);
}
if (cmp == 0) {
cmp = pattern2.length() - pattern1.length();
}
return cmp;
}
private int distance(String pattern) {
String example;
if (isRE(pattern)) {
example = shortestExample(pattern);
} else if (pattern.endsWith("/*")) {
example = pattern.substring(0, pattern.length() - 1) + '1';
} else if (pattern.equals(getRefName())) {
return 0;
} else {
return Math.max(pattern.length(), getRefName().length());
}
return StringUtils.getLevenshteinDistance(example, getRefName());
}
private boolean finite(String pattern) {
if (isRE(pattern)) {
return toRegExp(pattern).toAutomaton().isFinite();
} else if (pattern.endsWith("/*")) {
return false;
} else {
return true;
}
}
private int transitions(String pattern) {
if (isRE(pattern)) {
return toRegExp(pattern).toAutomaton().getNumberOfTransitions();
} else if (pattern.endsWith("/*")) {
return pattern.length();
} else {
return pattern.length();
}
}
};
/**
* Sorts all given rights into a map, ordered by descending length of
* ref pattern.
*
* For example, if given the following rights in argument:
*
* ["refs/heads/master", group1, -1, +1],
* ["refs/heads/master", group2, -2, +2],
* ["refs/heads/*", group3, -1, +1]
* ["refs/heads/stable", group2, -1, +1]
*
* Then the following map is returned:
* "refs/heads/master" => {
* ["refs/heads/master", group1, -1, +1],
* ["refs/heads/master", group2, -2, +2]
* }
* "refs/heads/stable" => {["refs/heads/stable", group2, -1, +1]}
* "refs/heads/*" => {["refs/heads/*", group3, -1, +1]}
*
* @param actionRights
* @return A sorted map keyed off the ref pattern of all rights.
*/
private SortedMap<String, RefRightsForPattern> sortedRightsByPattern(
List<RefRight> actionRights) {
SortedMap<String, RefRightsForPattern> rights =
new TreeMap<String, RefRightsForPattern>(BY_MOST_SPECIFIC_SORT);
for (RefRight actionRight : actionRights) {
RefRightsForPattern patternRights =
rights.get(actionRight.getRefPattern());
if (patternRights == null) {
patternRights = new RefRightsForPattern();
rights.put(actionRight.getRefPattern(), patternRights);
}
patternRights.addRight(actionRight);
}
return rights;
}
private List<RefRight> getAllRights(ApprovalCategory.Id actionId) {
final List<RefRight> allRefRights = filter(getProjectState().getAllRights(actionId, true));
return resolveOwnerGroups(allRefRights);
}
/**
* Returns all applicable rights for a given approval category.
*
* Applicable rights are defined as the list of {@code RefRight}s which match
* the ref for which this object was created, stopping the ref wildcard
* matching when an exclusive ref right was encountered, for the given
* approval category.
* @param id The {@link ApprovalCategory.Id}.
* @return All applicable rights.
*/
public List<RefRight> getApplicableRights(final ApprovalCategory.Id id) {
List<RefRight> l = new ArrayList<RefRight>();
l.addAll(getAllRights(id));
SortedMap<String, RefRightsForPattern> perPatternRights =
sortedRightsByPattern(l);
List<RefRight> applicable = new ArrayList<RefRight>();
for (RefRightsForPattern patternRights : perPatternRights.values()) {
applicable.addAll(patternRights.getRights());
if (patternRights.containsExclusive()) {
break;
}
}
return Collections.unmodifiableList(applicable);
}
/**
* Resolves all refRights which assign privileges to the 'Project Owners'
* group. All other refRights stay unchanged.
*
* @param refRights refRights to be resolved
* @return the resolved refRights
*/
private List<RefRight> resolveOwnerGroups(final List<RefRight> refRights) {
final List<RefRight> resolvedRefRights =
new ArrayList<RefRight>(refRights.size());
for (final RefRight refRight : refRights) {
resolvedRefRights.addAll(resolveOwnerGroups(refRight));
}
return resolvedRefRights;
}
/**
* Checks if the given refRight assigns privileges to the 'Project Owners'
* group.
* If yes, resolves the 'Project Owners' group to the concrete groups that
* own the project and creates new refRights for the concrete owner groups
* which are returned.
* If no, the given refRight is returned unchanged.
*
* @param refRight refRight
* @return the resolved refRights
*/
private Set<RefRight> resolveOwnerGroups(final RefRight refRight) {
final Set<RefRight> resolvedRefRights = new HashSet<RefRight>();
if (refRight.getAccountGroupId().equals(systemConfig.ownerGroupId)) {
for (final AccountGroup.Id ownerGroup : getProjectState().getAllOwners()) {
if (!ownerGroup.equals(systemConfig.ownerGroupId)) {
resolvedRefRights.add(new RefRight(refRight, ownerGroup));
}
}
} else {
resolvedRefRights.add(refRight);
}
return resolvedRefRights;
}
private List<RefRight> filter(Collection<RefRight> all) {
List<RefRight> mine = new ArrayList<RefRight>(all.size());
for (RefRight right : all) {
if (matches(right.getRefPattern())) {
mine.add(right);
}
}
return mine;
}
private ProjectState getProjectState() {
return projectControl.getProjectState();
}
private boolean matches(String refPattern) {
if (isTemplate(refPattern)) {
ParamertizedString template = new ParamertizedString(refPattern);
HashMap<String, String> p = new HashMap<String, String>();
if (getCurrentUser() instanceof IdentifiedUser) {
p.put("username", ((IdentifiedUser) getCurrentUser()).getUserName());
} else {
// Right now we only template the username. If not available
// this rule cannot be matched at all.
//
return false;
}
if (isRE(refPattern)) {
for (Map.Entry<String, String> ent : p.entrySet()) {
ent.setValue(escape(ent.getValue()));
}
}
refPattern = template.replace(p);
}
if (isRE(refPattern)) {
return Pattern.matches(refPattern, getRefName());
} else if (refPattern.endsWith("/*")) {
String prefix = refPattern.substring(0, refPattern.length() - 1);
return getRefName().startsWith(prefix);
} else {
return getRefName().equals(refPattern);
}
}
private static boolean isTemplate(String refPattern) {
return 0 <= refPattern.indexOf("${");
}
private static String escape(String value) {
// Right now the only special character allowed in a
// variable value is a . in the username.
//
return value.replace(".", "\\.");
}
private static boolean isRE(String refPattern) {
return refPattern.startsWith(RefRight.REGEX_PREFIX);
}
public static String shortestExample(String pattern) {
if (isRE(pattern)) {
return toRegExp(pattern).toAutomaton().getShortestExample(true);
} else if (pattern.endsWith("/*")) {
return pattern.substring(0, pattern.length() - 1) + '1';
} else {
return pattern;
}
}
private static RegExp toRegExp(String refPattern) {
if (isRE(refPattern)) {
refPattern = refPattern.substring(1);
}
return new RegExp(refPattern, RegExp.NONE);
}
}