Add REST API quota checks
This commit adds quota groups for all REST API calls. This enables enforcer implementations to throttle specific calls. Change-Id: Ideb182015519acecfbe304ef6a80ef38c854fb03
This commit is contained in:
@@ -14,8 +14,33 @@ associated.
|
|||||||
The following quota groups are defined in core Gerrit:
|
The following quota groups are defined in core Gerrit:
|
||||||
|
|
||||||
=== REST API
|
=== REST API
|
||||||
|
[[rest-api]]
|
||||||
|
|
||||||
TODO(hiesel,luca.milanesio,matthias.sohn,david.pursehouse): Add more :)
|
The REST API enforces quota after the resource was parsed (if applicable) and before the
|
||||||
|
endpoint's logic is executed. This enables quota enforcer implementations to throttle calls
|
||||||
|
to specific endpoints while knowing the general context (user and top-level entity such as
|
||||||
|
change, project or account).
|
||||||
|
|
||||||
|
If the quota enforcer wants to throttle HTTP requests, they should use
|
||||||
|
link:quota.html#http-requests[HTTP Requests] instead.
|
||||||
|
|
||||||
|
The quota groups used for checking follow the exact definition of the endoint in the REST
|
||||||
|
API, but remove all IDs. The schema is:
|
||||||
|
|
||||||
|
/restapi/<ENDPOINT>:<HTTP-METHOD>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
[options="header",cols="1,6"]
|
||||||
|
|=======================
|
||||||
|
|HTTP call |Quota Group |Metadata
|
||||||
|
|GET /a/changes/1/revisions/current/detail |/changes/revisions/detail:GET |CurrentUser, Change.Id, Project.NameKey
|
||||||
|
|POST /a/changes/ |/changes/:POST |CurrentUser
|
||||||
|
|GET /a/accounts/self/detail |/accounts/detail:GET |CurrentUser, Account.Id
|
||||||
|
|=======================
|
||||||
|
|
||||||
|
The user provided in the check's metadata is always the calling user (having the
|
||||||
|
impersonation bit and real user set in case the user is impersonating another user).
|
||||||
|
|
||||||
GERRIT
|
GERRIT
|
||||||
------
|
------
|
||||||
|
@@ -191,6 +191,11 @@ Preconditions] section.
|
|||||||
"`422 Unprocessable Entity`" is returned if the ID of a resource that is
|
"`422 Unprocessable Entity`" is returned if the ID of a resource that is
|
||||||
specified in the request body cannot be resolved.
|
specified in the request body cannot be resolved.
|
||||||
|
|
||||||
|
==== 429 Too Many Requests
|
||||||
|
"`429 Too Many Requests`" is returned if the request exhausted any set
|
||||||
|
quota limits. Depending on the exhausted quota, the request may be retried
|
||||||
|
with exponential backoff.
|
||||||
|
|
||||||
[[tracing]]
|
[[tracing]]
|
||||||
=== Request Tracing
|
=== Request Tracing
|
||||||
For each REST endpoint tracing can be enabled by setting the
|
For each REST endpoint tracing can be enabled by setting the
|
||||||
|
@@ -0,0 +1,83 @@
|
|||||||
|
// Copyright (C) 2018 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.httpd.restapi;
|
||||||
|
|
||||||
|
import com.google.gerrit.extensions.restapi.RestResource;
|
||||||
|
import com.google.gerrit.server.account.AccountResource;
|
||||||
|
import com.google.gerrit.server.change.ChangeResource;
|
||||||
|
import com.google.gerrit.server.project.ProjectResource;
|
||||||
|
import com.google.gerrit.server.quota.QuotaBackend;
|
||||||
|
import com.google.gerrit.server.quota.QuotaException;
|
||||||
|
import com.google.gerrit.util.http.RequestUtil;
|
||||||
|
import javax.inject.Inject;
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enforces quota on specific REST API endpoints.
|
||||||
|
*
|
||||||
|
* <p>Examples:
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>GET /a/accounts/self/detail => /restapi/accounts/detail:GET
|
||||||
|
* <li>GET /changes/123/revisions/current/detail => /restapi/changes/revisions/detail:GET
|
||||||
|
* <li>PUT /changes/10/reviewed => /changes/reviewed:PUT
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Adds context (change, project, account) to the quota check if the call is for an existing
|
||||||
|
* entity that was successfully parsed. This quota check is generally enforced after the resource
|
||||||
|
* was parsed, but before the view is executed. If a quota enforcer desires to throttle earlier,
|
||||||
|
* they should consider quota groups in the {@code /http/*} space.
|
||||||
|
*/
|
||||||
|
public class RestApiQuotaEnforcer {
|
||||||
|
private final QuotaBackend quotaBackend;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
RestApiQuotaEnforcer(QuotaBackend quotaBackend) {
|
||||||
|
this.quotaBackend = quotaBackend;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enforce quota on a request not tied to any {@code RestResource}. */
|
||||||
|
void enforce(HttpServletRequest req) throws QuotaException {
|
||||||
|
String pathForQuotaReporting = RequestUtil.getRestPathWithoutIds(req);
|
||||||
|
quotaBackend
|
||||||
|
.currentUser()
|
||||||
|
.requestToken(quotaGroup(pathForQuotaReporting, req.getMethod()))
|
||||||
|
.throwOnError();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enforce quota on a request for a given resource. */
|
||||||
|
void enforce(RestResource rsrc, HttpServletRequest req) throws QuotaException {
|
||||||
|
String pathForQuotaReporting = RequestUtil.getRestPathWithoutIds(req);
|
||||||
|
// Enrich the quota request we are operating on an interesting collection
|
||||||
|
QuotaBackend.WithResource report = quotaBackend.currentUser();
|
||||||
|
if (rsrc instanceof ChangeResource) {
|
||||||
|
ChangeResource changeResource = (ChangeResource) rsrc;
|
||||||
|
report =
|
||||||
|
quotaBackend.currentUser().change(changeResource.getId(), changeResource.getProject());
|
||||||
|
} else if (rsrc instanceof AccountResource) {
|
||||||
|
AccountResource accountResource = (AccountResource) rsrc;
|
||||||
|
report = quotaBackend.currentUser().account(accountResource.getUser().getAccountId());
|
||||||
|
} else if (rsrc instanceof ProjectResource) {
|
||||||
|
ProjectResource accountResource = (ProjectResource) rsrc;
|
||||||
|
report = quotaBackend.currentUser().account(accountResource.getUser().getAccountId());
|
||||||
|
}
|
||||||
|
|
||||||
|
report.requestToken(quotaGroup(pathForQuotaReporting, req.getMethod())).throwOnError();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String quotaGroup(String path, String method) {
|
||||||
|
return "/restapi" + path + ":" + method;
|
||||||
|
}
|
||||||
|
}
|
@@ -114,6 +114,7 @@ import com.google.gerrit.server.logging.TraceContext;
|
|||||||
import com.google.gerrit.server.permissions.GlobalPermission;
|
import com.google.gerrit.server.permissions.GlobalPermission;
|
||||||
import com.google.gerrit.server.permissions.PermissionBackend;
|
import com.google.gerrit.server.permissions.PermissionBackend;
|
||||||
import com.google.gerrit.server.permissions.PermissionBackendException;
|
import com.google.gerrit.server.permissions.PermissionBackendException;
|
||||||
|
import com.google.gerrit.server.quota.QuotaException;
|
||||||
import com.google.gerrit.server.update.UpdateException;
|
import com.google.gerrit.server.update.UpdateException;
|
||||||
import com.google.gerrit.server.util.time.TimeUtil;
|
import com.google.gerrit.server.util.time.TimeUtil;
|
||||||
import com.google.gerrit.util.http.CacheHeaders;
|
import com.google.gerrit.util.http.CacheHeaders;
|
||||||
@@ -227,6 +228,7 @@ public class RestApiServlet extends HttpServlet {
|
|||||||
final AuditService auditService;
|
final AuditService auditService;
|
||||||
final RestApiMetrics metrics;
|
final RestApiMetrics metrics;
|
||||||
final Pattern allowOrigin;
|
final Pattern allowOrigin;
|
||||||
|
final RestApiQuotaEnforcer quotaChecker;
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
Globals(
|
Globals(
|
||||||
@@ -236,6 +238,7 @@ public class RestApiServlet extends HttpServlet {
|
|||||||
PermissionBackend permissionBackend,
|
PermissionBackend permissionBackend,
|
||||||
AuditService auditService,
|
AuditService auditService,
|
||||||
RestApiMetrics metrics,
|
RestApiMetrics metrics,
|
||||||
|
RestApiQuotaEnforcer quotaChecker,
|
||||||
@GerritServerConfig Config cfg) {
|
@GerritServerConfig Config cfg) {
|
||||||
this.currentUser = currentUser;
|
this.currentUser = currentUser;
|
||||||
this.webSession = webSession;
|
this.webSession = webSession;
|
||||||
@@ -243,6 +246,7 @@ public class RestApiServlet extends HttpServlet {
|
|||||||
this.permissionBackend = permissionBackend;
|
this.permissionBackend = permissionBackend;
|
||||||
this.auditService = auditService;
|
this.auditService = auditService;
|
||||||
this.metrics = metrics;
|
this.metrics = metrics;
|
||||||
|
this.quotaChecker = quotaChecker;
|
||||||
allowOrigin = makeAllowOrigin(cfg);
|
allowOrigin = makeAllowOrigin(cfg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,6 +321,7 @@ public class RestApiServlet extends HttpServlet {
|
|||||||
viewData = new ViewData(null, null);
|
viewData = new ViewData(null, null);
|
||||||
|
|
||||||
if (path.isEmpty()) {
|
if (path.isEmpty()) {
|
||||||
|
globals.quotaChecker.enforce(req);
|
||||||
if (rc instanceof NeedsParams) {
|
if (rc instanceof NeedsParams) {
|
||||||
((NeedsParams) rc).setParams(qp.params());
|
((NeedsParams) rc).setParams(qp.params());
|
||||||
}
|
}
|
||||||
@@ -339,6 +344,7 @@ public class RestApiServlet extends HttpServlet {
|
|||||||
IdString id = path.remove(0);
|
IdString id = path.remove(0);
|
||||||
try {
|
try {
|
||||||
rsrc = rc.parse(rsrc, id);
|
rsrc = rc.parse(rsrc, id);
|
||||||
|
globals.quotaChecker.enforce(rsrc, req);
|
||||||
if (path.isEmpty()) {
|
if (path.isEmpty()) {
|
||||||
checkPreconditions(req);
|
checkPreconditions(req);
|
||||||
}
|
}
|
||||||
@@ -346,6 +352,7 @@ public class RestApiServlet extends HttpServlet {
|
|||||||
if (!path.isEmpty()) {
|
if (!path.isEmpty()) {
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
globals.quotaChecker.enforce(req);
|
||||||
|
|
||||||
if (isPost(req) || isPut(req)) {
|
if (isPost(req) || isPut(req)) {
|
||||||
RestView<RestResource> createView = rc.views().get(PluginName.GERRIT, "CREATE./");
|
RestView<RestResource> createView = rc.views().get(PluginName.GERRIT, "CREATE./");
|
||||||
@@ -602,6 +609,9 @@ public class RestApiServlet extends HttpServlet {
|
|||||||
status = SC_INTERNAL_SERVER_ERROR;
|
status = SC_INTERNAL_SERVER_ERROR;
|
||||||
responseBytes = handleException(e, req, res);
|
responseBytes = handleException(e, req, res);
|
||||||
}
|
}
|
||||||
|
} catch (QuotaException e) {
|
||||||
|
responseBytes =
|
||||||
|
replyError(req, res, status = 429, messageOr(e, "Quota limit reached"), e.caching(), e);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
status = SC_INTERNAL_SERVER_ERROR;
|
status = SC_INTERNAL_SERVER_ERROR;
|
||||||
responseBytes = handleException(e, req, res);
|
responseBytes = handleException(e, req, res);
|
||||||
|
@@ -56,5 +56,38 @@ public class RequestUtil {
|
|||||||
return pathInfo;
|
return pathInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trims leading '/' and 'a/'. Removes the context path, but keeps the servlet path. Removes all
|
||||||
|
* IDs from the rest of the URI.
|
||||||
|
*
|
||||||
|
* <p>The returned string is a good fit for cases where one wants the full context of the request
|
||||||
|
* without any identifiable data. For example: Logging or quota checks.
|
||||||
|
*
|
||||||
|
* <p>Examples:
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>/a/accounts/self/detail => /accounts/detail
|
||||||
|
* <li>/changes/123/revisions/current/detail => /changes/revisions/detail
|
||||||
|
* <li>/changes/ => /changes
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public static String getRestPathWithoutIds(HttpServletRequest req) {
|
||||||
|
String encodedPathInfo = req.getRequestURI().substring(req.getContextPath().length());
|
||||||
|
if (encodedPathInfo.startsWith("/")) {
|
||||||
|
encodedPathInfo = encodedPathInfo.substring(1);
|
||||||
|
}
|
||||||
|
if (encodedPathInfo.startsWith("a/")) {
|
||||||
|
encodedPathInfo = encodedPathInfo.substring(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
String[] parts = encodedPathInfo.split("/");
|
||||||
|
StringBuilder result = new StringBuilder(parts.length);
|
||||||
|
for (int i = 0; i < parts.length; i = i + 2) {
|
||||||
|
result.append("/");
|
||||||
|
result.append(parts[i]);
|
||||||
|
}
|
||||||
|
return result.toString();
|
||||||
|
}
|
||||||
|
|
||||||
private RequestUtil() {}
|
private RequestUtil() {}
|
||||||
}
|
}
|
||||||
|
@@ -15,6 +15,8 @@
|
|||||||
package com.google.gerrit.util.http;
|
package com.google.gerrit.util.http;
|
||||||
|
|
||||||
import static com.google.common.truth.Truth.assertThat;
|
import static com.google.common.truth.Truth.assertThat;
|
||||||
|
import static com.google.gerrit.util.http.RequestUtil.getEncodedPathInfo;
|
||||||
|
import static com.google.gerrit.util.http.RequestUtil.getRestPathWithoutIds;
|
||||||
|
|
||||||
import com.google.gerrit.testing.GerritBaseTests;
|
import com.google.gerrit.testing.GerritBaseTests;
|
||||||
import com.google.gerrit.util.http.testutil.FakeHttpServletRequest;
|
import com.google.gerrit.util.http.testutil.FakeHttpServletRequest;
|
||||||
@@ -22,36 +24,45 @@ import org.junit.Test;
|
|||||||
|
|
||||||
public class RequestUtilTest extends GerritBaseTests {
|
public class RequestUtilTest extends GerritBaseTests {
|
||||||
@Test
|
@Test
|
||||||
public void emptyContextPath() {
|
public void getEncodedPathInfo_emptyContextPath() {
|
||||||
assertThat(RequestUtil.getEncodedPathInfo(fakeRequest("", "/s", "/foo/bar")))
|
assertThat(getEncodedPathInfo(fakeRequest("", "/s", "/foo/bar"))).isEqualTo("/foo/bar");
|
||||||
.isEqualTo("/foo/bar");
|
assertThat(getEncodedPathInfo(fakeRequest("", "/s", "/foo%2Fbar"))).isEqualTo("/foo%2Fbar");
|
||||||
assertThat(RequestUtil.getEncodedPathInfo(fakeRequest("", "/s", "/foo%2Fbar")))
|
|
||||||
.isEqualTo("/foo%2Fbar");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void emptyServletPath() {
|
public void getEncodedPathInfo_emptyServletPath() {
|
||||||
assertThat(RequestUtil.getEncodedPathInfo(fakeRequest("", "/c", "/foo/bar")))
|
assertThat(getEncodedPathInfo(fakeRequest("", "/c", "/foo/bar"))).isEqualTo("/foo/bar");
|
||||||
.isEqualTo("/foo/bar");
|
assertThat(getEncodedPathInfo(fakeRequest("", "/c", "/foo%2Fbar"))).isEqualTo("/foo%2Fbar");
|
||||||
assertThat(RequestUtil.getEncodedPathInfo(fakeRequest("", "/c", "/foo%2Fbar")))
|
|
||||||
.isEqualTo("/foo%2Fbar");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void trailingSlashes() {
|
public void getEncodedPathInfo_trailingSlashes() {
|
||||||
assertThat(RequestUtil.getEncodedPathInfo(fakeRequest("/c", "/s", "/foo/bar/")))
|
assertThat(getEncodedPathInfo(fakeRequest("/c", "/s", "/foo/bar/"))).isEqualTo("/foo/bar/");
|
||||||
.isEqualTo("/foo/bar/");
|
assertThat(getEncodedPathInfo(fakeRequest("/c", "/s", "/foo/bar///"))).isEqualTo("/foo/bar/");
|
||||||
assertThat(RequestUtil.getEncodedPathInfo(fakeRequest("/c", "/s", "/foo/bar///")))
|
assertThat(getEncodedPathInfo(fakeRequest("/c", "/s", "/foo%2Fbar/"))).isEqualTo("/foo%2Fbar/");
|
||||||
.isEqualTo("/foo/bar/");
|
assertThat(getEncodedPathInfo(fakeRequest("/c", "/s", "/foo%2Fbar///")))
|
||||||
assertThat(RequestUtil.getEncodedPathInfo(fakeRequest("/c", "/s", "/foo%2Fbar/")))
|
|
||||||
.isEqualTo("/foo%2Fbar/");
|
|
||||||
assertThat(RequestUtil.getEncodedPathInfo(fakeRequest("/c", "/s", "/foo%2Fbar///")))
|
|
||||||
.isEqualTo("/foo%2Fbar/");
|
.isEqualTo("/foo%2Fbar/");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void emptyPathInfo() {
|
public void emptyPathInfo() {
|
||||||
assertThat(RequestUtil.getEncodedPathInfo(fakeRequest("/c", "/s", ""))).isNull();
|
assertThat(getEncodedPathInfo(fakeRequest("/c", "/s", ""))).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void getRestPathWithoutIds_emptyContextPath() {
|
||||||
|
assertThat(getRestPathWithoutIds(fakeRequest("", "/a/accounts", "/123/test")))
|
||||||
|
.isEqualTo("/accounts/test");
|
||||||
|
assertThat(getRestPathWithoutIds(fakeRequest("", "/accounts", "/123/test")))
|
||||||
|
.isEqualTo("/accounts/test");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void getRestPathWithoutIds_nonEmptyContextPath() {
|
||||||
|
assertThat(getRestPathWithoutIds(fakeRequest("/c", "/a/accounts", "/123/test")))
|
||||||
|
.isEqualTo("/accounts/test");
|
||||||
|
assertThat(getRestPathWithoutIds(fakeRequest("/c", "/accounts", "/123/test")))
|
||||||
|
.isEqualTo("/accounts/test");
|
||||||
}
|
}
|
||||||
|
|
||||||
private FakeHttpServletRequest fakeRequest(
|
private FakeHttpServletRequest fakeRequest(
|
||||||
|
Reference in New Issue
Block a user