diff --git a/lib/errors/arsenalErrors.ts b/lib/errors/arsenalErrors.ts index 54f5550e1..28c5f5d31 100644 --- a/lib/errors/arsenalErrors.ts +++ b/lib/errors/arsenalErrors.ts @@ -241,7 +241,7 @@ export const InvalidTag: ErrorFormat = { export const InvalidTargetBucketForLogging: ErrorFormat = { code: 400, description: - 'The target bucket for logging does not exist, is not owned by you, '+ + 'The target bucket for logging does not exist, is not owned by you, '+ 'or does not have the appropriate grants for the log-delivery group.', }; @@ -402,11 +402,17 @@ export const ObjectLockConfigurationNotFoundError: ErrorFormat = { description: 'The object lock configuration was not found', }; + export const ServerSideEncryptionConfigurationNotFoundError: ErrorFormat = { code: 404, description: 'The server side encryption configuration was not found', }; +export const NoSuchRateLimitConfig: ErrorFormat = { + code: 404, + description: 'The bucket rate limit configuration does not exist.', +}; + export const NotImplemented: ErrorFormat = { code: 501, description: @@ -483,8 +489,8 @@ export const SignatureDoesNotMatch: ErrorFormat = { description: 'The request signature we calculated does not match the signature you provided.', }; -// "This is an AWS S3 specific error. We are opting to use the more general 'ServiceUnavailable' -// error used throughout AWS (IAM/EC2) to have uniformity of error messages even though we are +// "This is an AWS S3 specific error. We are opting to use the more general 'ServiceUnavailable' +// error used throughout AWS (IAM/EC2) to have uniformity of error messages even though we are // potentially compromising S3 compatibility.", // export const ServiceUnavailable: ErrorFormat = { diff --git a/lib/models/BucketInfo.ts b/lib/models/BucketInfo.ts index 750015d07..2d57ae92f 100644 --- a/lib/models/BucketInfo.ts +++ b/lib/models/BucketInfo.ts @@ -12,11 +12,12 @@ import { areTagsValid, BucketTag } from '../s3middleware/tagging'; import { VeeamCapability, VeeamSOSApiSchema, VeeamSOSApiSerializable } from './Veeam'; import { AzureInfoMetadata } from './BucketAzureInfo'; import BucketLoggingStatus from './BucketLoggingStatus'; +import RateLimitConfiguration, { RateLimitConfigurationMetadata } from './RateLimitConfiguration'; // WHEN UPDATING THIS NUMBER, UPDATE BucketInfoModelVersion.md CHANGELOG // BucketInfoModelVersion.md can be found in documentation/ at the root // of this repository -const modelVersion = 18; +const modelVersion = 19; export type CORS = { id: string; @@ -80,6 +81,7 @@ export type BucketMetadata = { capabilities?: Capabilities, quotaMax: bigint | number, bucketLoggingStatus?: BucketLoggingStatus, + rateLimitConfiguration?: RateLimitConfigurationMetadata, }; export type BucketMetadataJSON = Omit & { @@ -118,6 +120,7 @@ export default class BucketInfo implements BucketMetadata { private _capabilities?: Capabilities; private _quotaMax: bigint; private _bucketLoggingStatus?: BucketLoggingStatus; + private _rateLimitConfiguration?: RateLimitConfiguration; /** * Represents all bucket information. @@ -205,6 +208,7 @@ export default class BucketInfo implements BucketMetadata { capabilities?: Capabilities, quotaMax?: bigint | number, bucketLoggingStatus?: BucketLoggingStatus, + rateLimitConfiguration?: RateLimitConfiguration, ) { assert.strictEqual(typeof name, 'string'); assert.strictEqual(typeof owner, 'string'); @@ -228,7 +232,7 @@ export default class BucketInfo implements BucketMetadata { assert.strictEqual(typeof cryptoScheme, 'number'); assert.strictEqual(typeof algorithm, 'string'); assert.strictEqual(typeof mandatory, 'boolean'); - assert.ok(masterKeyId !== undefined || configuredMasterKeyId !== undefined, + assert.ok(masterKeyId !== undefined || configuredMasterKeyId !== undefined, 'At least one of masterKeyId or configuredMasterKeyId must be defined'); if (masterKeyId !== undefined) { assert.strictEqual(typeof masterKeyId, 'string', 'masterKeyId must be a string'); @@ -368,8 +372,9 @@ export default class BucketInfo implements BucketMetadata { VeeamSOSApi: capabilities.VeeamSOSApi && VeeamCapability.toBigInt(capabilities.VeeamSOSApi), }; - + this._quotaMax = BigInt(quotaMax || 0n); + this._rateLimitConfiguration = rateLimitConfiguration; return this; } @@ -411,6 +416,7 @@ export default class BucketInfo implements BucketMetadata { }, quotaMax: this._quotaMax.toString(), bucketLoggingStatus: this._bucketLoggingStatus, + rateLimitConfiguration: this._rateLimitConfiguration?.getData(), }; const final = this._websiteConfiguration ? { @@ -445,6 +451,8 @@ export default class BucketInfo implements BucketMetadata { new WebsiteConfiguration(obj.websiteConfiguration) : undefined; const bucketLoggingStatus = obj.bucketLoggingStatus ? new BucketLoggingStatus((obj.bucketLoggingStatus as any)._loggingEnabled) : undefined; + const rateLimitConfiguration = obj.rateLimitConfiguration ? + new RateLimitConfiguration(obj.rateLimitConfiguration) : undefined; return new BucketInfo(obj.name, obj.owner, obj.ownerDisplayName, obj.creationDate, obj.mdBucketModelVersion, obj.acl, obj.transient, obj.deleted, obj.serverSideEncryption, @@ -453,7 +461,7 @@ export default class BucketInfo implements BucketMetadata { obj.bucketPolicy, obj.uid, obj.readLocationConstraint, obj.isNFS, obj.ingestion, obj.azureInfo, obj.objectLockEnabled, obj.objectLockConfiguration, obj.notificationConfiguration, obj.tags, - capabilities, BigInt(obj.quotaMax || 0n), bucketLoggingStatus); + capabilities, BigInt(obj.quotaMax || 0n), bucketLoggingStatus, rateLimitConfiguration); } /** @@ -486,7 +494,8 @@ export default class BucketInfo implements BucketMetadata { data._isNFS, data._ingestion, data._azureInfo, data._objectLockEnabled, data._objectLockConfiguration, data._notificationConfiguration, data._tags, capabilities, - BigInt(data._quotaMax || 0n), data._bucketLoggingStatus); + BigInt(data._quotaMax || 0n), data._bucketLoggingStatus, + data._rateLimitConfiguration); } /** @@ -498,6 +507,8 @@ export default class BucketInfo implements BucketMetadata { static fromJson(data: BucketMetadataJSON) { const bucketLoggingStatus = data.bucketLoggingStatus ? new BucketLoggingStatus((data.bucketLoggingStatus as any)._loggingEnabled) : undefined; + const rateLimitConfiguration = data.rateLimitConfiguration ? + new RateLimitConfiguration(data.rateLimitConfiguration) : undefined; return new BucketInfo(data.name, data.owner, data.ownerDisplayName, data.creationDate, data.mdBucketModelVersion, data.acl, data.transient, data.deleted, data.serverSideEncryption, @@ -511,7 +522,7 @@ export default class BucketInfo implements BucketMetadata { ...data.capabilities, VeeamSOSApi: data.capabilities?.VeeamSOSApi && VeeamCapability.parse(data.capabilities?.VeeamSOSApi), - }, BigInt(data.quotaMax || 0n), bucketLoggingStatus); + }, BigInt(data.quotaMax || 0n), bucketLoggingStatus, rateLimitConfiguration); } /** @@ -738,9 +749,9 @@ export default class BucketInfo implements BucketMetadata { /** * Checks if the default encryption is set at the account level instead of the legacy bucket level. - * This method helps to prevent deletion of the account-level master encryption key when deleting buckets. + * This method helps to prevent deletion of the account-level master encryption key when deleting buckets. * - * @returns {boolean} - Returns true if account-level default encryption is enabled, + * @returns {boolean} - Returns true if account-level default encryption is enabled, * false if it uses the legacy bucket level. */ isAccountEncryptionEnabled() { @@ -1027,7 +1038,7 @@ export default class BucketInfo implements BucketMetadata { getTags() { return this._tags; } - + /** * Set bucket tags * @return - bucket info instance @@ -1047,7 +1058,7 @@ export default class BucketInfo implements BucketMetadata { /** * Get a specific bucket capability - * + * * @param capability? - if provided, will return a specific capacity * @return - capability of the bucket */ @@ -1057,7 +1068,7 @@ export default class BucketInfo implements BucketMetadata { } return undefined; } - + /** * Set bucket capabilities * @return - bucket info instance @@ -1074,7 +1085,7 @@ export default class BucketInfo implements BucketMetadata { getQuota() { return this._quotaMax; } - + /** * Set bucket quota * @param quota - quota to be set @@ -1102,4 +1113,12 @@ export default class BucketInfo implements BucketMetadata { this._bucketLoggingStatus = bucketLoggingStatus; return this; } + + getRateLimitConfiguration(): RateLimitConfiguration | undefined { + return this._rateLimitConfiguration; + } + + setRateLimitConfiguration(value: RateLimitConfiguration) { + this._rateLimitConfiguration = value; + } } diff --git a/lib/models/RateLimitConfiguration.ts b/lib/models/RateLimitConfiguration.ts new file mode 100644 index 000000000..95c5fda2c --- /dev/null +++ b/lib/models/RateLimitConfiguration.ts @@ -0,0 +1,38 @@ + +export type LimitConfiguration = { + Limit: number; +}; + +export type RateLimitConfigurationMetadata = { + RequestsPerSecond?: LimitConfiguration +}; + +export default class RateLimitConfiguration { + private readonly _data: RateLimitConfigurationMetadata; + + constructor(obj: RateLimitConfigurationMetadata) { + this._data = obj; + } + + getRequestsPerSecondLimit(): number | undefined { + return this._data.RequestsPerSecond?.Limit; + } + + setRequestsPerSecondLimit(value: number): RateLimitConfiguration { + this._data.RequestsPerSecond = { + ...(this._data.RequestsPerSecond || {}), + Limit: value, + }; + + return this; + } + + removeRequestsPerSecondLimit(): RateLimitConfiguration { + delete this._data.RequestsPerSecond; + return this; + } + + getData(): RateLimitConfigurationMetadata { + return this._data; + } +} diff --git a/lib/models/index.ts b/lib/models/index.ts index 1655ba571..3562ca223 100644 --- a/lib/models/index.ts +++ b/lib/models/index.ts @@ -14,4 +14,5 @@ export { default as ObjectMDAzureInfo } from './ObjectMDAzureInfo'; export { default as ObjectMDLocation } from './ObjectMDLocation'; export { default as ReplicationConfiguration } from './ReplicationConfiguration'; export { default as BucketLoggingStatus } from './BucketLoggingStatus'; +export { default as RateLimitConfiguration } from './RateLimitConfiguration'; export * as WebsiteConfiguration from './WebsiteConfiguration'; diff --git a/lib/s3routes/routes/routeDELETE.ts b/lib/s3routes/routes/routeDELETE.ts index 54fc4f620..a5ca8c779 100644 --- a/lib/s3routes/routes/routeDELETE.ts +++ b/lib/s3routes/routes/routeDELETE.ts @@ -43,6 +43,8 @@ export default function routeDELETE( return call('bucketDeleteTagging'); } else if (query?.quota !== undefined) { return call('bucketDeleteQuota'); + } else if (query?.['rate-limit'] !== undefined) { + return call('bucketDeleteRateLimit'); } return call('bucketDelete'); } else { @@ -57,7 +59,7 @@ export default function routeDELETE( * be sent back as a response. */ if (err && ( - !(err instanceof ArsenalError) || + !(err instanceof ArsenalError) || (!err.is.NoSuchKey && !err.is.NoSuchVersion) )) { return routesUtils.responseNoBody(err, corsHeaders, diff --git a/lib/s3routes/routes/routeGET.ts b/lib/s3routes/routes/routeGET.ts index 9a9ed9bb2..7c3c867c1 100644 --- a/lib/s3routes/routes/routeGET.ts +++ b/lib/s3routes/routes/routeGET.ts @@ -64,6 +64,8 @@ export default function routerGET( call('bucketGetQuota'); } else if (query.logging !== undefined) { call('bucketGetLogging'); + } else if (query['rate-limit'] !== undefined) { + call('bucketGetRateLimit'); } else { // GET bucket call('bucketGet'); diff --git a/lib/s3routes/routes/routePUT.ts b/lib/s3routes/routes/routePUT.ts index 85b1bbdc1..4f1bd9cfd 100644 --- a/lib/s3routes/routes/routePUT.ts +++ b/lib/s3routes/routes/routePUT.ts @@ -119,6 +119,13 @@ export default function routePUT( return routesUtils.responseNoBody(err, resHeaders, response, 200, log); }); + } else if (query['rate-limit'] !== undefined) { + api.callApiMethod('bucketPutRateLimit', request, response, + log, (err, resHeaders) => { + routesUtils.statsReport500(err, statsClient); + return routesUtils.responseNoBody(err, resHeaders, response, + 200, log); + }); } else { // PUT bucket return api.callApiMethod('bucketPut', request, response, log, diff --git a/tests/unit/models/RateLimitConfiguration.spec.ts b/tests/unit/models/RateLimitConfiguration.spec.ts new file mode 100644 index 000000000..dae141a65 --- /dev/null +++ b/tests/unit/models/RateLimitConfiguration.spec.ts @@ -0,0 +1,55 @@ +import assert from 'assert'; + +import RateLimitConfiguration from '../../../lib/models/RateLimitConfiguration'; + +describe('Test RateLimitConfiguration', () => { + it('should create an empty RateLimitConfiguration', () => { + const rlc = new RateLimitConfiguration({}); + assert.deepStrictEqual(rlc.getData(), {}); + }); + + it('should create a RateLimitConfiguration with a RequestsPerSecond limit', () => { + const rlc = new RateLimitConfiguration({ + RequestsPerSecond: { + Limit: 1000, + }, + }); + assert.deepStrictEqual(rlc.getData(), { + RequestsPerSecond: { + Limit: 1000, + }, + }); + }); + + it('should return RequestsPerSecond.Limit if set', () => { + const rlc = new RateLimitConfiguration({ + RequestsPerSecond: { + Limit: 1000, + }, + }); + assert.strictEqual(rlc.getRequestsPerSecondLimit(), 1000); + }); + + it('should return undefined if RequestsPerSecond.Limit is not set', () => { + const rlc = new RateLimitConfiguration({}); + assert.strictEqual(rlc.getRequestsPerSecondLimit(), undefined); + }); + + it('should set RequestsPerSecond.Limit', () => { + const rlc = new RateLimitConfiguration({}); + assert.strictEqual(rlc.getRequestsPerSecondLimit(), undefined); + rlc.setRequestsPerSecondLimit(1000); + assert.strictEqual(rlc.getRequestsPerSecondLimit(), 1000); + }); + + it('should remove RequestsPerSecond.Limit', () => { + const rlc = new RateLimitConfiguration({ + RequestsPerSecond: { + Limit: 1000, + }, + }); + assert.strictEqual(rlc.getRequestsPerSecondLimit(), 1000); + rlc.removeRequestsPerSecondLimit(); + assert.strictEqual(rlc.getRequestsPerSecondLimit(), undefined); + }); +}); diff --git a/tests/unit/s3routes/routeDELETE.spec.js b/tests/unit/s3routes/routeDELETE.spec.js index 07e16df48..b729881db 100644 --- a/tests/unit/s3routes/routeDELETE.spec.js +++ b/tests/unit/s3routes/routeDELETE.spec.js @@ -138,4 +138,15 @@ describe('routeDELETE', () => { otherError, {}, response, undefined, log, ); }); + + it('should call bucketDeleteRateLimit if query.rate-limit is set', () => { + request.query = { 'rate-limit': '' }; + request.objectKey = undefined; + + routeDELETE(request, response, api, log, statsClient); + + expect(api.callApiMethod).toHaveBeenCalledWith( + 'bucketDeleteRateLimit', request, response, log, expect.any(Function), + ); + }); }); diff --git a/tests/unit/s3routes/routeGET.spec.js b/tests/unit/s3routes/routeGET.spec.js index 169e45106..eb106662a 100644 --- a/tests/unit/s3routes/routeGET.spec.js +++ b/tests/unit/s3routes/routeGET.spec.js @@ -101,6 +101,17 @@ describe('routerGET', () => { ); }); + it('should call bucketGetRateLimit when query.rate-limit is present', () => { + request.bucketName = 'bucketName'; + request.query = { 'rate-limit': '' }; + + routerGET(request, response, api, log, statsClient, dataRetrievalParams); + + expect(api.callApiMethod).toHaveBeenCalledWith( + 'bucketGetRateLimit', request, response, log, expect.any(Function), + ); + }); + it('should handle objectGet with responseStreamData when no query is present for an object', () => { request.bucketName = 'bucketName'; request.objectKey = 'objectKey'; diff --git a/tests/unit/s3routes/routePUT.spec.js b/tests/unit/s3routes/routePUT.spec.js index 47617a15b..89ad100fb 100644 --- a/tests/unit/s3routes/routePUT.spec.js +++ b/tests/unit/s3routes/routePUT.spec.js @@ -248,6 +248,18 @@ describe('routePUT', () => { ); }); + it('should call bucketPutRateLimit when query.rate-limit is set', () => { + request.bucketName = 'test-bucket'; + request.query = { 'rate-limit': '' }; + api.callApiMethod = jest.fn(); + + routePUT(request, response, api, log, statsClient); + + expect(api.callApiMethod).toHaveBeenCalledWith( + 'bucketPutRateLimit', request, response, log, expect.any(Function), + ); + }); + it('should return BadRequest when content-length is invalid for PUT bucket', () => { request.bucketName = 'test-bucket'; request.query = {};