diff --git a/doc/release-notes/9356-rate-limiting.md b/doc/release-notes/9356-rate-limiting.md
new file mode 100644
index 00000000000..1d68669af26
--- /dev/null
+++ b/doc/release-notes/9356-rate-limiting.md
@@ -0,0 +1,20 @@
+## Rate Limiting using JCache (with Hazelcast as provided by Payara)
+The option to rate limit has been added to prevent users from over taxing the system either deliberately or by runaway automated processes.
+Rate limiting can be configured on a tier level with tier 0 being reserved for guest users and tiers 1-any for authenticated users.
+Superuser accounts are exempt from rate limiting.
+Rate limits can be imposed on command APIs by configuring the tier, the command, and the hourly limit in the database.
+Two database settings configure the rate limiting.
+Note: If either of these settings exist in the database rate limiting will be enabled.
+If neither setting exists rate limiting is disabled.
+
+`:RateLimitingDefaultCapacityTiers` is a comma separated list of default values for each tier.
+In the following example, the default for tier `0` (guest users) is set to 10,000 calls per command per hour and tier `1` (authenticated users) is set to 20,000 calls per command per hour.
+Tiers not specified in this setting will default to `-1` (No Limit). I.e., -d "10000" is equivalent to -d "10000,-1,-1,..."
+`curl http://localhost:8080/api/admin/settings/:RateLimitingDefaultCapacityTiers -X PUT -d '10000,20000'`
+
+`:RateLimitingCapacityByTierAndAction` is a JSON object specifying the rate by tier and a list of actions (commands).
+This allows for more control over the rate limit of individual API command calls.
+In the following example, calls made by a guest user (tier 0) for API `GetLatestPublishedDatasetVersionCommand` is further limited to only 10 calls per hour, while an authenticated user (tier 1) will be able to make 30 calls per hour to the same API.
+`curl http://localhost:8080/api/admin/settings/:RateLimitingCapacityByTierAndAction -X PUT -d '[{"tier": 0, "limitPerHour": 10, "actions": ["GetLatestPublishedDatasetVersionCommand", "GetPrivateUrlCommand", "GetDatasetCommand", "GetLatestAccessibleDatasetVersionCommand"]}, {"tier": 0, "limitPerHour": 1, "actions": ["CreateGuestbookResponseCommand", "UpdateDatasetVersionCommand", "DestroyDatasetCommand", "DeleteDataFileCommand", "FinalizeDatasetPublicationCommand", "PublishDatasetCommand"]}, {"tier": 1, "limitPerHour": 30, "actions": ["CreateGuestbookResponseCommand", "GetLatestPublishedDatasetVersionCommand", "GetPrivateUrlCommand", "GetDatasetCommand", "GetLatestAccessibleDatasetVersionCommand", "UpdateDatasetVersionCommand", "DestroyDatasetCommand", "DeleteDataFileCommand", "FinalizeDatasetPublicationCommand", "PublishDatasetCommand"]}]'`
+
+Hazelcast is configured in Payara and should not need any changes for this feature
\ No newline at end of file
diff --git a/doc/sphinx-guides/source/_static/installation/files/examples/rate-limit-actions-setting.json b/doc/sphinx-guides/source/_static/installation/files/examples/rate-limit-actions-setting.json
new file mode 100644
index 00000000000..3dfc7648dc3
--- /dev/null
+++ b/doc/sphinx-guides/source/_static/installation/files/examples/rate-limit-actions-setting.json
@@ -0,0 +1,40 @@
+[
+ {
+ "tier": 0,
+ "limitPerHour": 10,
+ "actions": [
+ "GetLatestPublishedDatasetVersionCommand",
+ "GetPrivateUrlCommand",
+ "GetDatasetCommand",
+ "GetLatestAccessibleDatasetVersionCommand"
+ ]
+ },
+ {
+ "tier": 0,
+ "limitPerHour": 1,
+ "actions": [
+ "CreateGuestbookResponseCommand",
+ "UpdateDatasetVersionCommand",
+ "DestroyDatasetCommand",
+ "DeleteDataFileCommand",
+ "FinalizeDatasetPublicationCommand",
+ "PublishDatasetCommand"
+ ]
+ },
+ {
+ "tier": 1,
+ "limitPerHour": 30,
+ "actions": [
+ "CreateGuestbookResponseCommand",
+ "GetLatestPublishedDatasetVersionCommand",
+ "GetPrivateUrlCommand",
+ "GetDatasetCommand",
+ "GetLatestAccessibleDatasetVersionCommand",
+ "UpdateDatasetVersionCommand",
+ "DestroyDatasetCommand",
+ "DeleteDataFileCommand",
+ "FinalizeDatasetPublicationCommand",
+ "PublishDatasetCommand"
+ ]
+ }
+]
\ No newline at end of file
diff --git a/doc/sphinx-guides/source/installation/config.rst b/doc/sphinx-guides/source/installation/config.rst
index 2baa2827250..1a3ef88a5aa 100644
--- a/doc/sphinx-guides/source/installation/config.rst
+++ b/doc/sphinx-guides/source/installation/config.rst
@@ -1373,6 +1373,33 @@ Before being moved there,
on your machine, large file uploads via API will cause RAM and/or swap usage bursts. You might want to point this to
a different location, restrict maximum size of it, and monitor for stale uploads.
+.. _cache-rate-limiting:
+
+Configure Your Dataverse Installation to Use JCache (with Hazelcast as Provided by Payara) for Rate Limiting
+------------------------------------------------------------------------------------------------------------
+
+Rate limiting has been added to prevent users from over taxing the system either deliberately or by runaway automated processes.
+Rate limiting can be configured on a tier level with tier 0 being reserved for guest users and tiers 1-any for authenticated users.
+Superuser accounts are exempt from rate limiting.
+Rate limits can be imposed on command APIs by configuring the tier, the command, and the hourly limit in the database.
+Two database settings configure the rate limiting.
+Note: If either of these settings exist in the database rate limiting will be enabled (note that a Payara restart is required for the setting to take effect). If neither setting exists rate limiting is disabled.
+
+- :RateLimitingDefaultCapacityTiers is the number of calls allowed per hour if the specific command is not configured. The values represent the number of calls per hour per user for tiers 0,1,...
+ A value of -1 can be used to signify no rate limit. Tiers not specified in this setting will default to `-1` (No Limit). I.e., -d "10000" is equivalent to -d "10000,-1,-1,..."
+
+.. code-block:: bash
+
+ curl http://localhost:8080/api/admin/settings/:RateLimitingDefaultCapacityTiers -X PUT -d '10000,20000'
+
+- :RateLimitingCapacityByTierAndAction is a JSON object specifying the rate by tier and a list of actions (commands). This allows for more control over the rate limit of individual API command calls.
+ In the following example, calls made by a guest user (tier 0) for API GetLatestPublishedDatasetVersionCommand is further limited to only 10 calls per hour, while an authenticated user (tier 1) will be able to make 30 calls per hour to the same API.
+
+:download:`rate-limit-actions.json ` Example json for RateLimitingCapacityByTierAndAction
+
+.. code-block:: bash
+
+ curl http://localhost:8080/api/admin/settings/:RateLimitingCapacityByTierAndAction -X PUT -d '[{"tier": 0, "limitPerHour": 10, "actions": ["GetLatestPublishedDatasetVersionCommand", "GetPrivateUrlCommand", "GetDatasetCommand", "GetLatestAccessibleDatasetVersionCommand"]}, {"tier": 0, "limitPerHour": 1, "actions": ["CreateGuestbookResponseCommand", "UpdateDatasetVersionCommand", "DestroyDatasetCommand", "DeleteDataFileCommand", "FinalizeDatasetPublicationCommand", "PublishDatasetCommand"]}, {"tier": 1, "limitPerHour": 30, "actions": ["CreateGuestbookResponseCommand", "GetLatestPublishedDatasetVersionCommand", "GetPrivateUrlCommand", "GetDatasetCommand", "GetLatestAccessibleDatasetVersionCommand", "UpdateDatasetVersionCommand", "DestroyDatasetCommand", "DeleteDataFileCommand", "FinalizeDatasetPublicationCommand", "PublishDatasetCommand"]}]'
.. _Branding Your Installation:
@@ -4496,3 +4523,17 @@ tab. files saved with these headers on S3 - since they no longer have
to be generated and added to the streamed file on the fly.
The setting is ``false`` by default, preserving the legacy behavior.
+
+:RateLimitingDefaultCapacityTiers
++++++++++++++++++++++++++++++++++
+Number of calls allowed per hour if the specific command is not configured. The values represent the number of calls per hour per user for tiers 0,1,...
+A value of -1 can be used to signify no rate limit. Also, by default, a tier not defined would receive a default of no limit.
+
+:RateLimitingCapacityByTierAndAction
+++++++++++++++++++++++++++++++++++++
+JSON object specifying the rate by tier and a list of actions (commands). This allows for more control over the rate limit of individual API command calls.
+In the following example, calls made by a guest user (tier 0) for API GetLatestPublishedDatasetVersionCommand is further limited to only 10 calls per hour, while an authenticated user (tier 1) will be able to make 30 calls per hour to the same API.
+{"rateLimits":[
+{"tier": 0, "limitPerHour": 10, "actions": ["GetLatestPublishedDatasetVersionCommand", "GetPrivateUrlCommand", "GetDatasetCommand", "GetLatestAccessibleDatasetVersionCommand"]},
+{"tier": 0, "limitPerHour": 1, "actions": ["CreateGuestbookResponseCommand", "UpdateDatasetVersionCommand", "DestroyDatasetCommand", "DeleteDataFileCommand", "FinalizeDatasetPublicationCommand", "PublishDatasetCommand"]},
+{"tier": 1, "limitPerHour": 30, "actions": ["CreateGuestbookResponseCommand", "GetLatestPublishedDatasetVersionCommand", "GetPrivateUrlCommand", "GetDatasetCommand", "GetLatestAccessibleDatasetVersionCommand", "UpdateDatasetVersionCommand", "DestroyDatasetCommand", "DeleteDataFileCommand", "FinalizeDatasetPublicationCommand", "PublishDatasetCommand"]}]}
diff --git a/pom.xml b/pom.xml
index 8b2850e1df9..f736f04cf32 100644
--- a/pom.xml
+++ b/pom.xml
@@ -210,6 +210,18 @@
provided
+
+
+ jakarta.json.bind
+ jakarta.json.bind-api
+
+
+
+ org.eclipse
+ yasson
+ test
+
+
org.glassfish
@@ -542,6 +554,10 @@
dataverse-spi2.0.0
+
+ javax.cache
+ cache-api
+ org.junit.jupiter
@@ -653,6 +669,11 @@
3.9.0test
+
+ com.hazelcast
+ hazelcast
+ test
+
diff --git a/src/main/java/edu/harvard/iq/dataverse/DatasetPage.java b/src/main/java/edu/harvard/iq/dataverse/DatasetPage.java
index 05325a26f3a..4daa1fbadaf 100644
--- a/src/main/java/edu/harvard/iq/dataverse/DatasetPage.java
+++ b/src/main/java/edu/harvard/iq/dataverse/DatasetPage.java
@@ -24,6 +24,7 @@
import edu.harvard.iq.dataverse.engine.command.Command;
import edu.harvard.iq.dataverse.engine.command.CommandContext;
import edu.harvard.iq.dataverse.engine.command.exception.CommandException;
+import edu.harvard.iq.dataverse.engine.command.impl.CheckRateLimitForDatasetPage;
import edu.harvard.iq.dataverse.engine.command.impl.CreatePrivateUrlCommand;
import edu.harvard.iq.dataverse.engine.command.impl.CuratePublishedDatasetVersionCommand;
import edu.harvard.iq.dataverse.engine.command.impl.DeaccessionDatasetVersionCommand;
@@ -36,6 +37,7 @@
import edu.harvard.iq.dataverse.engine.command.impl.PublishDataverseCommand;
import edu.harvard.iq.dataverse.engine.command.impl.UpdateDatasetVersionCommand;
import edu.harvard.iq.dataverse.export.ExportService;
+import edu.harvard.iq.dataverse.util.cache.CacheFactoryBean;
import io.gdcc.spi.export.ExportException;
import io.gdcc.spi.export.Exporter;
import edu.harvard.iq.dataverse.ingest.IngestRequest;
@@ -242,6 +244,8 @@ public enum DisplayMode {
SolrClientService solrClientService;
@EJB
DvObjectServiceBean dvObjectService;
+ @EJB
+ CacheFactoryBean cacheFactory;
@Inject
DataverseRequestServiceBean dvRequestService;
@Inject
@@ -1930,7 +1934,10 @@ private void setIdByPersistentId() {
}
private String init(boolean initFull) {
-
+ // Check for rate limit exceeded. Must be done before anything else to prevent unnecessary processing.
+ if (!cacheFactory.checkRate(session.getUser(), new CheckRateLimitForDatasetPage(null,null))) {
+ return BundleUtil.getStringFromBundle("command.exception.user.ratelimited", Arrays.asList(CheckRateLimitForDatasetPage.class.getSimpleName()));
+ }
//System.out.println("_YE_OLDE_QUERY_COUNTER_"); // for debug purposes
setDataverseSiteUrl(systemConfig.getDataverseSiteUrl());
diff --git a/src/main/java/edu/harvard/iq/dataverse/DataversePage.java b/src/main/java/edu/harvard/iq/dataverse/DataversePage.java
index 10dfa4a0e4f..4f0a3f14b99 100644
--- a/src/main/java/edu/harvard/iq/dataverse/DataversePage.java
+++ b/src/main/java/edu/harvard/iq/dataverse/DataversePage.java
@@ -9,6 +9,7 @@
import edu.harvard.iq.dataverse.dataverse.DataverseUtil;
import edu.harvard.iq.dataverse.engine.command.Command;
import edu.harvard.iq.dataverse.engine.command.exception.CommandException;
+import edu.harvard.iq.dataverse.engine.command.impl.CheckRateLimitForCollectionPage;
import edu.harvard.iq.dataverse.engine.command.impl.CreateDataverseCommand;
import edu.harvard.iq.dataverse.engine.command.impl.CreateSavedSearchCommand;
import edu.harvard.iq.dataverse.engine.command.impl.DeleteDataverseCommand;
@@ -31,6 +32,8 @@
import static edu.harvard.iq.dataverse.util.JsfHelper.JH;
import edu.harvard.iq.dataverse.util.SystemConfig;
import java.util.List;
+
+import edu.harvard.iq.dataverse.util.cache.CacheFactoryBean;
import jakarta.ejb.EJB;
import jakarta.faces.application.FacesMessage;
import jakarta.faces.context.FacesContext;
@@ -118,6 +121,8 @@ public enum LinkMode {
@Inject DataverseHeaderFragment dataverseHeaderFragment;
@EJB
PidProviderFactoryBean pidProviderFactoryBean;
+ @EJB
+ CacheFactoryBean cacheFactory;
private Dataverse dataverse = new Dataverse();
@@ -318,7 +323,10 @@ public void updateOwnerDataverse() {
public String init() {
//System.out.println("_YE_OLDE_QUERY_COUNTER_"); // for debug purposes
-
+ // Check for rate limit exceeded. Must be done before anything else to prevent unnecessary processing.
+ if (!cacheFactory.checkRate(session.getUser(), new CheckRateLimitForCollectionPage(null,null))) {
+ return BundleUtil.getStringFromBundle("command.exception.user.ratelimited", Arrays.asList(CheckRateLimitForCollectionPage.class.getSimpleName()));
+ }
if (this.getAlias() != null || this.getId() != null || this.getOwnerId() == null) {// view mode for a dataverse
if (this.getAlias() != null) {
dataverse = dataverseService.findByAlias(this.getAlias());
diff --git a/src/main/java/edu/harvard/iq/dataverse/EjbDataverseEngine.java b/src/main/java/edu/harvard/iq/dataverse/EjbDataverseEngine.java
index 3793b6eeeb4..bb3fa475847 100644
--- a/src/main/java/edu/harvard/iq/dataverse/EjbDataverseEngine.java
+++ b/src/main/java/edu/harvard/iq/dataverse/EjbDataverseEngine.java
@@ -4,6 +4,7 @@
import edu.harvard.iq.dataverse.actionlogging.ActionLogServiceBean;
import edu.harvard.iq.dataverse.authorization.AuthenticationServiceBean;
import edu.harvard.iq.dataverse.authorization.providers.builtin.BuiltinUserServiceBean;
+import edu.harvard.iq.dataverse.util.cache.CacheFactoryBean;
import edu.harvard.iq.dataverse.engine.DataverseEngine;
import edu.harvard.iq.dataverse.authorization.Permission;
import edu.harvard.iq.dataverse.authorization.groups.GroupServiceBean;
@@ -16,6 +17,7 @@
import edu.harvard.iq.dataverse.engine.command.DataverseRequest;
import edu.harvard.iq.dataverse.engine.command.exception.CommandException;
import edu.harvard.iq.dataverse.engine.command.exception.PermissionException;
+import edu.harvard.iq.dataverse.engine.command.exception.RateLimitCommandException;
import edu.harvard.iq.dataverse.ingest.IngestServiceBean;
import edu.harvard.iq.dataverse.pidproviders.PidProviderFactoryBean;
import edu.harvard.iq.dataverse.privateurl.PrivateUrlServiceBean;
@@ -176,7 +178,9 @@ public class EjbDataverseEngine {
@EJB
EjbDataverseEngineInner innerEngine;
-
+
+ @EJB
+ CacheFactoryBean cacheFactory;
@Resource
EJBContext ejbCtxt;
@@ -202,7 +206,11 @@ public R submit(Command aCommand) throws CommandException {
try {
logRec.setUserIdentifier( aCommand.getRequest().getUser().getIdentifier() );
-
+ // Check for rate limit exceeded. Must be done before anything else to prevent unnecessary processing.
+ if (!cacheFactory.checkRate(aCommand.getRequest().getUser(), aCommand)) {
+ throw new RateLimitCommandException(BundleUtil.getStringFromBundle("command.exception.user.ratelimited", Arrays.asList(aCommand.getClass().getSimpleName())), aCommand);
+ }
+
// Check permissions - or throw an exception
Map> requiredMap = aCommand.getRequiredPermissions();
if (requiredMap == null) {
diff --git a/src/main/java/edu/harvard/iq/dataverse/UserServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/UserServiceBean.java
index 93892376edc..d63fcfa3e34 100644
--- a/src/main/java/edu/harvard/iq/dataverse/UserServiceBean.java
+++ b/src/main/java/edu/harvard/iq/dataverse/UserServiceBean.java
@@ -147,6 +147,8 @@ private AuthenticatedUser createAuthenticatedUserForView (Object[] dbRowValues,
user.setMutedEmails(Type.tokenizeToSet((String) dbRowValues[15]));
user.setMutedNotifications(Type.tokenizeToSet((String) dbRowValues[15]));
+ user.setRateLimitTier((int)dbRowValues[17]);
+
user.setRoles(roles);
return user;
}
@@ -419,7 +421,7 @@ private List