com.google.gerrit.server.permissions.PermissionBackend Maven / Gradle / Ivy
// Copyright (C) 2017 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.permissions;
import static java.util.Objects.requireNonNull;
import static java.util.stream.Collectors.toSet;
import com.google.auto.value.AutoValue;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Sets;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.UsedAt;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.LabelType;
import com.google.gerrit.entities.Project;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.extensions.api.access.CoreOrPluginProjectPermission;
import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
import com.google.gerrit.extensions.conditions.BooleanCondition;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.inject.ImplementedBy;
import java.util.Collection;
import java.util.Collections;
import java.util.EnumSet;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
import org.eclipse.jgit.errors.RepositoryNotFoundException;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
/**
 * Checks authorization to perform an action on a project, reference, or change.
 *
 * {@code check} methods should be used during action handlers to verify the user is allowed to
 * exercise the specified permission. For convenience in implementation {@code check} methods throw
 * {@link AuthException} if the permission is denied.
 *
 * 
{@code test} methods should be used when constructing replies to the client and the result
 * object needs to include a true/false hint indicating the user's ability to exercise the
 * permission. This is suitable for configuring UI button state, but should not be relied upon to
 * guard handlers before making state changes.
 *
 * 
{@code PermissionBackend} is a singleton for the server, acting as a factory for lightweight
 * request instances. Implementation classes may cache supporting data inside of {@link WithUser},
 * {@link ForProject}, {@link ForRef}, and {@link ForChange} instances, in addition to storing
 * within {@link CurrentUser} using a {@link com.google.gerrit.server.PropertyMap.Key}. {@link
 * GlobalPermission} caching for {@link WithUser} may best cached inside {@link CurrentUser} as
 * {@link WithUser} instances are frequently created.
 *
 * 
Example use:
 *
 * 
 *   private final PermissionBackend permissions;
 *   private final Provider user;
 *
 *   {@literal @}Inject
 *   Foo(PermissionBackend permissions, Provider user) {
 *     this.permissions = permissions;
 *     this.user = user;
 *   }
 *
 *   public void apply(...) {
 *     permissions.user(user).change(cd).check(ChangePermission.SUBMIT);
 *   }
 *
 *   public UiAction.Description getDescription(ChangeResource rsrc) {
 *     return new UiAction.Description()
 *       .setLabel("Submit")
 *       .setVisible(rsrc.permissions().testCond(ChangePermission.SUBMIT));
 * }
 *   
 */
@ImplementedBy(DefaultPermissionBackend.class)
public abstract class PermissionBackend {
  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
  /** Returns an instance scoped to the current user. */
  public abstract WithUser currentUser();
  /**
   * Returns an instance scoped to the specified user. Should be used in cases where the user could
   * either be the issuer of the current request or an impersonated user. PermissionBackends that do
   * not support impersonation can fail with an {@code IllegalStateException}.
   *
   * If an instance scoped to the current user is desired, use {@code currentUser()} instead.
   */
  public abstract WithUser user(CurrentUser user);
  /**
   * Returns an instance scoped to the provided user. Should be used in cases where the caller wants
   * to check the permissions of a user who is not the issuer of the current request and not the
   * target of impersonation.
   *
   * 
Usage should be very limited as this can expose a group-oracle.
   */
  public abstract WithUser absentUser(Account.Id id);
  /**
   * Check whether this {@code PermissionBackend} respects the same global capabilities as the
   * {@link DefaultPermissionBackend}.
   *
   * 
If true, then it makes sense for downstream callers to refer to built-in Gerrit capability
   * names in user-facing error messages, for example.
   *
   * @return whether this is the default permission backend.
   */
  public boolean usesDefaultCapabilities() {
    return false;
  }
  /**
   * Throw {@link ResourceNotFoundException} if this backend does not use the default global
   * capabilities.
   */
  public void checkUsesDefaultCapabilities() throws ResourceNotFoundException {
    if (!usesDefaultCapabilities()) {
      throw new ResourceNotFoundException("Gerrit capabilities not used on this server");
    }
  }
  /**
   * Bulk evaluate a set of {@link PermissionBackendCondition} for view handling.
   *
   * 
Overridden implementations should call {@link PermissionBackendCondition#set(boolean)} to
   * cache the result of {@code testOrFalse} in the condition for later evaluation. Caching the
   * result will bypass the usual invocation of {@code testOrFalse}.
   *
   * @param conds conditions to consider.
   */
  public void bulkEvaluateTest(Set conds) {
    // Do nothing by default. The default implementation of PermissionBackendCondition
    // delegates to the appropriate testOrFalse method in PermissionBackend.
  }
  /** PermissionBackend scoped to a specific user. */
  public abstract static class WithUser {
    /** Returns an instance scoped for the specified project. */
    public abstract ForProject project(Project.NameKey project);
    /** Returns an instance scoped for the {@code ref}, and its parent project. */
    public ForRef ref(BranchNameKey ref) {
      return project(ref.project()).ref(ref.branch());
    }
    /** Returns an instance scoped for the change, and its destination ref and project. */
    public ForChange change(ChangeData cd) {
      try {
        return ref(cd.change().getDest()).change(cd);
      } catch (StorageException e) {
        return FailedPermissionBackend.change("unavailable", e);
      }
    }
    /** Returns an instance scoped for the change, and its destination ref and project. */
    public ForChange change(ChangeNotes notes) {
      return ref(notes.getChange().getDest()).change(notes);
    }
    /**
     * Verify scoped user can {@code perm}, throwing if denied.
     *
     * Should be used in REST API handlers where the thrown {@link AuthException} can be
     * propagated. In business logic, where the exception would have to be caught, prefer using
     * {@link #test(GlobalOrPluginPermission)}.
     */
    public abstract void check(GlobalOrPluginPermission perm)
        throws AuthException, PermissionBackendException;
    /**
     * Verify scoped user can perform at least one listed permission.
     *
     * 
If {@code any} is empty, the method completes normally and allows the caller to continue.
     * Since no permissions were supplied to check, its assumed no permissions are necessary to
     * continue with the caller's operation.
     *
     * 
If the user has at least one of the permissions in {@code any}, the method completes
     * normally, possibly without checking all listed permissions.
     *
     * 
If {@code any} is non-empty and the user has none, {@link AuthException} is thrown for one
     * of the failed permissions.
     *
     * @param any set of permissions to check.
     */
    public void checkAny(Set any)
        throws PermissionBackendException, AuthException {
      for (Iterator itr = any.iterator(); itr.hasNext(); ) {
        try {
          check(itr.next());
          return;
        } catch (AuthException err) {
          if (!itr.hasNext()) {
            throw err;
          }
        }
      }
    }
    /** Filter {@code permSet} to permissions scoped user might be able to perform. */
    public abstract  Set test(Collection permSet)
        throws PermissionBackendException;
    public boolean test(GlobalOrPluginPermission perm) throws PermissionBackendException {
      return test(Collections.singleton(perm)).contains(perm);
    }
    public boolean testOrFalse(GlobalOrPluginPermission perm) {
      try {
        return test(perm);
      } catch (PermissionBackendException e) {
        logger.atWarning().withCause(e).log("Cannot test %s; assuming false", perm);
        return false;
      }
    }
    public abstract BooleanCondition testCond(GlobalOrPluginPermission perm);
    /**
     * Filter a set of projects using {@code check(perm)}.
     *
     * @param perm required permission in a project to be included in result.
     * @param projects candidate set of projects; may be empty.
     * @return filtered set of {@code projects} where {@code check(perm)} was successful.
     * @throws PermissionBackendException backend cannot access its internal state.
     */
    public Set filter(ProjectPermission perm, Collection projects)
        throws PermissionBackendException {
      requireNonNull(perm, "ProjectPermission");
      requireNonNull(projects, "projects");
      Set allowed = Sets.newHashSetWithExpectedSize(projects.size());
      for (Project.NameKey project : projects) {
        try {
          if (project(project).test(perm)) {
            allowed.add(project);
          }
        } catch (PermissionBackendException e) {
          if (e.getCause() instanceof RepositoryNotFoundException) {
            logger.atWarning().withCause(e).log(
                "Could not find repository of the project %s", project.get());
            // Do not include this project because doesn't exist
          } else {
            throw e;
          }
        }
      }
      return allowed;
    }
    /**
     * Additional filter for changes query for reducing the cardinality of the results for current
     * user.
     *
     * @return additional query filter to add to all user's change queries, null if no filters are
     *     required.
     * @since 3.11
     */
    @UsedAt(UsedAt.Project.MODULE_VIRTUALHOST)
    public @Nullable String filterQueryChanges() {
      return null;
    }
  }
  /** PermissionBackend scoped to a user and project. */
  public abstract static class ForProject {
    /** Returns the fully qualified resource path that this instance is scoped to. */
    public abstract String resourcePath();
    /** Returns an instance scoped for {@code ref} in this project. */
    public abstract ForRef ref(String ref);
    /** Returns an instance scoped for the change, and its destination ref and project. */
    public ForChange change(ChangeData cd) {
      try {
        return ref(cd.branchOrThrow().branch()).change(cd);
      } catch (StorageException e) {
        return FailedPermissionBackend.change("unavailable", e);
      }
    }
    /** Returns an instance scoped for the change, and its destination ref and project. */
    public ForChange change(ChangeNotes notes) {
      return ref(notes.getChange().getDest().branch()).change(notes);
    }
    /**
     * Verify scoped user can {@code perm}, throwing if denied.
     *
     * Should be used in REST API handlers where the thrown {@link AuthException} can be
     * propagated. In business logic, where the exception would have to be caught, prefer using
     * {@link #test(CoreOrPluginProjectPermission)}.
     */
    public abstract void check(CoreOrPluginProjectPermission perm)
        throws AuthException, PermissionBackendException;
    /** Filter {@code permSet} to permissions scoped user might be able to perform. */
    public abstract  Set test(Collection permSet)
        throws PermissionBackendException;
    public boolean test(CoreOrPluginProjectPermission perm) throws PermissionBackendException {
      if (perm instanceof ProjectPermission) {
        return test(EnumSet.of((ProjectPermission) perm)).contains(perm);
      }
      // TODO(xchangcheng): implement for plugin defined project permissions.
      return false;
    }
    public boolean testOrFalse(CoreOrPluginProjectPermission perm) {
      try {
        return test(perm);
      } catch (PermissionBackendException e) {
        logger.atWarning().withCause(e).log("Cannot test %s; assuming false", perm);
        return false;
      }
    }
    public abstract BooleanCondition testCond(CoreOrPluginProjectPermission perm);
    /**
     * Filter a list of references by visibility.
     *
     * @param refs a collection of references to filter.
     * @param repo an open {@link Repository} handle for this instance's project
     * @param opts further options for filtering.
     * @return a partition of the provided refs that are visible to the user that this instance is
     *     scoped to.
     * @throws PermissionBackendException if failure consulting backend configuration.
     */
    public abstract Collection filter(
        Collection refs, Repository repo, RefFilterOptions opts)
        throws PermissionBackendException;
  }
  /** Options for filtering refs using {@link ForProject}. */
  @AutoValue
  public abstract static class RefFilterOptions {
    /** Remove all NoteDb refs (refs/changes/*, refs/users/*, edit refs) from the result. */
    public abstract boolean filterMeta();
    /**
     * Select only refs with names matching prefixes per {@link
     * org.eclipse.jgit.lib.RefDatabase#getRefsByPrefix}.
     */
    public abstract ImmutableList prefixes();
    public abstract Builder toBuilder();
    public static Builder builder() {
      return new AutoValue_PermissionBackend_RefFilterOptions.Builder()
          .setFilterMeta(false)
          .setPrefixes(Collections.singletonList(""));
    }
    @AutoValue.Builder
    public abstract static class Builder {
      public abstract Builder setFilterMeta(boolean val);
      public abstract Builder setPrefixes(List prefixes);
      public abstract RefFilterOptions build();
    }
    public static RefFilterOptions defaults() {
      return builder().build();
    }
  }
  /** PermissionBackend scoped to a user, project and reference. */
  public abstract static class ForRef {
    /** Returns a fully qualified resource path that this instance is scoped to. */
    public abstract String resourcePath();
    /** Returns an instance scoped to change. */
    public abstract ForChange change(ChangeData cd);
    /** Returns an instance scoped to change. */
    public abstract ForChange change(ChangeNotes notes);
    /**
     * Verify scoped user can {@code perm}, throwing if denied.
     *
     * Should be used in REST API handlers where the thrown {@link AuthException} can be
     * propagated. In business logic, where the exception would have to be caught, prefer using
     * {@link #test(RefPermission)}.
     */
    public abstract void check(RefPermission perm) throws AuthException, PermissionBackendException;
    /** Filter {@code permSet} to permissions scoped user might be able to perform. */
    public abstract Set test(Collection permSet)
        throws PermissionBackendException;
    public boolean test(RefPermission perm) throws PermissionBackendException {
      return test(EnumSet.of(perm)).contains(perm);
    }
    /**
     * Test if user may be able to perform the permission.
     *
     * Similar to {@link #test(RefPermission)} except this method returns {@code false} instead
     * of throwing an exception.
     *
     * @param perm the permission to test.
     * @return true if the user might be able to perform the permission; false if the user may be
     *     missing the necessary grants or state, or if the backend threw an exception.
     */
    public boolean testOrFalse(RefPermission perm) {
      try {
        return test(perm);
      } catch (PermissionBackendException e) {
        logger.atWarning().withCause(e).log("Cannot test %s; assuming false", perm);
        return false;
      }
    }
    public abstract BooleanCondition testCond(RefPermission perm);
  }
  /** PermissionBackend scoped to a user, project, reference and change. */
  public abstract static class ForChange {
    /** Returns the fully qualified resource path that this instance is scoped to. */
    public abstract String resourcePath();
    /**
     * Verify scoped user can {@code perm}, throwing if denied.
     *
     * 
Should be used in REST API handlers where the thrown {@link AuthException} can be
     * propagated. In business logic, where the exception would have to be caught, prefer using
     * {@link #test(ChangePermissionOrLabel)}.
     */
    public abstract void check(ChangePermissionOrLabel perm)
        throws AuthException, PermissionBackendException;
    /** Filter {@code permSet} to permissions scoped user might be able to perform. */
    public abstract  Set test(Collection permSet)
        throws PermissionBackendException;
    public boolean test(ChangePermissionOrLabel perm) throws PermissionBackendException {
      return test(Collections.singleton(perm)).contains(perm);
    }
    /**
     * Test if user may be able to perform the permission.
     *
     * Similar to {@link #test(ChangePermissionOrLabel)} except this method returns {@code false}
     * instead of throwing an exception.
     *
     * @param perm the permission to test.
     * @return true if the user might be able to perform the permission; false if the user may be
     *     missing the necessary grants or state, or if the backend threw an exception.
     */
    public boolean testOrFalse(ChangePermissionOrLabel perm) {
      try {
        return test(perm);
      } catch (PermissionBackendException e) {
        logger.atWarning().withCause(e).log("Cannot test %s; assuming false", perm);
        return false;
      }
    }
    public abstract BooleanCondition testCond(ChangePermissionOrLabel perm);
    /**
     * Test which values of a label the user may be able to set.
     *
     * @param label definition of the label to test values of.
     * @return set containing values the user may be able to use; may be empty if none.
     * @throws PermissionBackendException if failure consulting backend configuration.
     */
    public Set test(LabelType label) throws PermissionBackendException {
      return test(valuesOf(requireNonNull(label, "LabelType")));
    }
    /**
     * Test which values of a label the user may be able to remove.
     *
     * @param label definition of the label to test values of.
     * @return set containing values the user may be able to use; may be empty if none.
     * @throws PermissionBackendException if failure consulting backend configuration.
     */
    public Set testRemoval(LabelType label)
        throws PermissionBackendException {
      return test(removalValuesOf(requireNonNull(label, "LabelType")));
    }
    /**
     * Test which values of a group of labels the user may be able to set.
     *
     * @param types definition of the labels to test values of.
     * @return set containing values the user may be able to use; may be empty if none.
     * @throws PermissionBackendException if failure consulting backend configuration.
     */
    public Set testLabels(Collection types)
        throws PermissionBackendException {
      requireNonNull(types, "LabelType");
      return test(types.stream().flatMap(t -> valuesOf(t).stream()).collect(toSet()));
    }
    /**
     * Test which values of a group of labels the user may be able to remove.
     *
     * @param types definition of the labels to test values of.
     * @return set containing values the user may be able to use; may be empty if none.
     * @throws PermissionBackendException if failure consulting backend configuration.
     */
    public Set testLabelRemovals(Collection types)
        throws PermissionBackendException {
      requireNonNull(types, "LabelType");
      return test(types.stream().flatMap(t -> removalValuesOf(t).stream()).collect(toSet()));
    }
    private static Set valuesOf(LabelType label) {
      return label.getValues().stream()
          .map(v -> new LabelPermission.WithValue(label, v))
          .collect(toSet());
    }
    private static Set removalValuesOf(LabelType label) {
      return label.getValues().stream()
          .map(v -> new LabelRemovalPermission.WithValue(label, v))
          .collect(toSet());
    }
  }
}