From 8650df4a3fcf2f243835d028ed908a49f3c64a27 Mon Sep 17 00:00:00 2001 From: "ankitatripathi.mp@gmail.com" Date: Fri, 29 May 2026 12:24:17 +0530 Subject: [PATCH 1/3] feat: add deploy-cli support for Rate Limit Policies (EA) --- src/context/directory/handlers/index.ts | 2 + .../directory/handlers/rateLimitPolicies.ts | 63 +++++ src/context/yaml/handlers/index.ts | 2 + .../yaml/handlers/rateLimitPolicies.ts | 39 +++ src/tools/auth0/handlers/index.ts | 2 + src/tools/auth0/handlers/rateLimitPolicies.ts | 242 ++++++++++++++++ src/tools/constants.ts | 1 + src/types.ts | 5 +- .../directory/rateLimitPolicies.test.ts | 157 +++++++++++ test/context/yaml/context.test.js | 3 + test/context/yaml/rateLimitPolicies.test.ts | 111 ++++++++ .../auth0/handlers/rateLimitPolicies.test.ts | 258 ++++++++++++++++++ test/utils.js | 3 + 13 files changed, 887 insertions(+), 1 deletion(-) create mode 100644 src/context/directory/handlers/rateLimitPolicies.ts create mode 100644 src/context/yaml/handlers/rateLimitPolicies.ts create mode 100644 src/tools/auth0/handlers/rateLimitPolicies.ts create mode 100644 test/context/directory/rateLimitPolicies.test.ts create mode 100644 test/context/yaml/rateLimitPolicies.test.ts create mode 100644 test/tools/auth0/handlers/rateLimitPolicies.test.ts diff --git a/src/context/directory/handlers/index.ts b/src/context/directory/handlers/index.ts index 97eeb3b93..723fa9b7e 100644 --- a/src/context/directory/handlers/index.ts +++ b/src/context/directory/handlers/index.ts @@ -38,6 +38,7 @@ import userAttributeProfiles from './userAttributeProfiles'; import connectionProfiles from './connectionProfiles'; import tokenExchangeProfiles from './tokenExchangeProfiles'; import supplementalSignals from './supplementalSignals'; +import rateLimitPolicies from './rateLimitPolicies'; import DirectoryContext from '..'; import { AssetTypes, Asset } from '../../../types'; @@ -92,6 +93,7 @@ const directoryHandlers: { connectionProfiles, tokenExchangeProfiles, supplementalSignals, + rateLimitPolicies, }; export default directoryHandlers; diff --git a/src/context/directory/handlers/rateLimitPolicies.ts b/src/context/directory/handlers/rateLimitPolicies.ts new file mode 100644 index 000000000..12e29ddae --- /dev/null +++ b/src/context/directory/handlers/rateLimitPolicies.ts @@ -0,0 +1,63 @@ +import path from 'path'; +import fs from 'fs-extra'; +import { constants } from '../../../tools'; +import { getFiles, existsMustBeDir, dumpJSON, loadJSON, sanitize } from '../../../utils'; +import { DirectoryHandler } from '.'; +import DirectoryContext from '..'; +import { ParsedAsset } from '../../../types'; +import { RateLimitPolicy } from '../../../tools/auth0/handlers/rateLimitPolicies'; + +type ParsedRateLimitPolicies = ParsedAsset<'rateLimitPolicies', RateLimitPolicy[]>; + +function parse(context: DirectoryContext): ParsedRateLimitPolicies { + const rateLimitPoliciesDirectory = path.join( + context.filePath, + constants.RATE_LIMIT_POLICIES_DIRECTORY + ); + if (!existsMustBeDir(rateLimitPoliciesDirectory)) return { rateLimitPolicies: null }; // Skip + + const foundFiles = getFiles(rateLimitPoliciesDirectory, ['.json']); + + const rateLimitPolicies = foundFiles + .map((f) => + loadJSON(f, { + mappings: context.mappings, + disableKeywordReplacement: context.disableKeywordReplacement, + }) + ) + .filter((p) => Object.keys(p).length > 0); + + return { rateLimitPolicies }; +} + +async function dump(context: DirectoryContext): Promise { + const { rateLimitPolicies } = context.assets; + + if (!rateLimitPolicies) return; // Skip, nothing to dump + + const rateLimitPoliciesDirectory = path.join( + context.filePath, + constants.RATE_LIMIT_POLICIES_DIRECTORY + ); + fs.ensureDirSync(rateLimitPoliciesDirectory); + + const removeKeysFromOutput = ['id', 'created_at', 'updated_at']; + + rateLimitPolicies.forEach((policy) => { + const policyToWrite = { ...policy }; + removeKeysFromOutput.forEach((key) => { + delete policyToWrite[key]; + }); + + const fileName = sanitize(policy.consumer_selector); + const filePath = path.join(rateLimitPoliciesDirectory, `${fileName}.json`); + dumpJSON(filePath, policyToWrite); + }); +} + +const rateLimitPoliciesHandler: DirectoryHandler = { + parse, + dump, +}; + +export default rateLimitPoliciesHandler; diff --git a/src/context/yaml/handlers/index.ts b/src/context/yaml/handlers/index.ts index 4848cdbdf..a40d974e1 100644 --- a/src/context/yaml/handlers/index.ts +++ b/src/context/yaml/handlers/index.ts @@ -38,6 +38,7 @@ import userAttributeProfiles from './userAttributeProfiles'; import connectionProfiles from './connectionProfiles'; import tokenExchangeProfiles from './tokenExchangeProfiles'; import supplementalSignals from './supplementalSignals'; +import rateLimitPolicies from './rateLimitPolicies'; import YAMLContext from '..'; import { AssetTypes } from '../../../types'; @@ -90,6 +91,7 @@ const yamlHandlers: { [key in AssetTypes]: YAMLHandler<{ [key: string]: unknown connectionProfiles, tokenExchangeProfiles, supplementalSignals, + rateLimitPolicies, }; export default yamlHandlers; diff --git a/src/context/yaml/handlers/rateLimitPolicies.ts b/src/context/yaml/handlers/rateLimitPolicies.ts new file mode 100644 index 000000000..7f7c03e10 --- /dev/null +++ b/src/context/yaml/handlers/rateLimitPolicies.ts @@ -0,0 +1,39 @@ +import { YAMLHandler } from '.'; +import YAMLContext from '..'; +import { ParsedAsset } from '../../../types'; +import { RateLimitPolicy } from '../../../tools/auth0/handlers/rateLimitPolicies'; + +type ParsedRateLimitPolicies = ParsedAsset<'rateLimitPolicies', RateLimitPolicy[]>; + +async function parse(context: YAMLContext): Promise { + const { rateLimitPolicies } = context.assets; + + if (!rateLimitPolicies) return { rateLimitPolicies: null }; + + return { rateLimitPolicies }; +} + +async function dump(context: YAMLContext): Promise { + const { rateLimitPolicies } = context.assets; + + if (!rateLimitPolicies) return { rateLimitPolicies: null }; + + const removeKeysFromOutput = ['id', 'created_at', 'updated_at']; + + const cleaned = rateLimitPolicies.map((policy) => { + const policyToWrite = { ...policy }; + removeKeysFromOutput.forEach((key) => { + delete policyToWrite[key]; + }); + return policyToWrite; + }); + + return { rateLimitPolicies: cleaned }; +} + +const rateLimitPoliciesHandler: YAMLHandler = { + parse, + dump, +}; + +export default rateLimitPoliciesHandler; diff --git a/src/tools/auth0/handlers/index.ts b/src/tools/auth0/handlers/index.ts index eb582b6a4..3e3503f00 100644 --- a/src/tools/auth0/handlers/index.ts +++ b/src/tools/auth0/handlers/index.ts @@ -39,6 +39,7 @@ import * as userAttributeProfiles from './userAttributeProfiles'; import * as connectionProfiles from './connectionProfiles'; import * as tokenExchangeProfiles from './tokenExchangeProfiles'; import * as supplementalSignals from './supplementalSignals'; +import * as rateLimitPolicies from './rateLimitPolicies'; import { AssetTypes } from '../../../types'; import APIHandler from './default'; @@ -86,6 +87,7 @@ const auth0ApiHandlers: { [key in AssetTypes]: any } = { connectionProfiles, tokenExchangeProfiles, supplementalSignals, + rateLimitPolicies, }; export default auth0ApiHandlers as { diff --git a/src/tools/auth0/handlers/rateLimitPolicies.ts b/src/tools/auth0/handlers/rateLimitPolicies.ts new file mode 100644 index 000000000..8731e5605 --- /dev/null +++ b/src/tools/auth0/handlers/rateLimitPolicies.ts @@ -0,0 +1,242 @@ +import DefaultAPIHandler from './default'; +import { Asset, Assets, CalculatedChanges } from '../../../types'; +import { paginate } from '../client'; +import log from '../../../logger'; + +// Types will align with Management.RateLimitPolicy once node-auth0 PR #1348 is merged +export type RateLimitPolicyConfiguration = + | { action: 'allow' } + | { action: 'block' | 'log'; limit: number } + | { action: 'redirect'; limit: number; redirect_uri: string }; + +export type RateLimitPolicy = { + id?: string; + resource: string; + consumer: string; + consumer_selector: string; + configuration: RateLimitPolicyConfiguration; + created_at?: string; + updated_at?: string; +}; + +export const schema = { + type: 'array', + items: { + type: 'object', + properties: { + resource: { + type: 'string', + enum: ['oauth_authentication_api'], + }, + consumer: { + type: 'string', + enum: ['client'], + }, + consumer_selector: { + type: 'string', + }, + configuration: { + type: 'object', + oneOf: [ + { + required: ['action'], + properties: { + action: { type: 'string', enum: ['allow'] }, + }, + additionalProperties: false, + }, + { + required: ['action', 'limit'], + properties: { + action: { type: 'string', enum: ['block', 'log'] }, + limit: { type: 'number' }, + }, + additionalProperties: false, + }, + { + required: ['action', 'limit', 'redirect_uri'], + properties: { + action: { type: 'string', enum: ['redirect'] }, + limit: { type: 'number' }, + redirect_uri: { type: 'string' }, + }, + additionalProperties: false, + }, + ], + }, + }, + required: ['resource', 'consumer', 'consumer_selector', 'configuration'], + additionalProperties: false, + }, +}; + +export default class RateLimitPoliciesHandler extends DefaultAPIHandler { + existing: RateLimitPolicy[] | null; + + constructor(config: DefaultAPIHandler) { + super({ + ...config, + type: 'rateLimitPolicies', + id: 'id', + identifiers: ['id', 'consumer_selector'], + stripCreateFields: ['id', 'created_at', 'updated_at'], + stripUpdateFields: [ + 'id', + 'resource', + 'consumer', + 'consumer_selector', + 'created_at', + 'updated_at', + ], + }); + } + + objString(policy: RateLimitPolicy): string { + return super.objString({ + consumer_selector: policy.consumer_selector, + resource: policy.resource, + }); + } + + async getType(): Promise { + if (this.existing) return this.existing; + + try { + const rateLimitPolicies = await paginate( + this.client.rateLimitPolicies.list, + { checkpoint: true } + ); + this.existing = rateLimitPolicies; + return this.existing; + } catch (err) { + if (err.statusCode === 404 || err.statusCode === 501) { + return null; + } + if (err.statusCode === 403) { + log.debug( + 'Rate Limit Policies feature is not enabled for this tenant. Please contact Auth0 support to enable this feature.' + ); + return null; + } + throw err; + } + } + + async processChanges(assets: Assets): Promise { + const { rateLimitPolicies } = assets; + + if (!rateLimitPolicies) return; + + const { del, update, create } = await this.calcChanges(assets); + + log.debug( + `Start processChanges for rateLimitPolicies [delete:${del.length}] [update:${update.length}], [create:${create.length}]` + ); + + const changes = [{ del }, { create }, { update }]; + + await Promise.all( + changes.map(async (change) => { + switch (true) { + case change.del && change.del.length > 0: + await this.deleteRateLimitPolicies(change.del || []); + break; + case change.create && change.create.length > 0: + await this.createRateLimitPolicies(change.create); + break; + case change.update && change.update.length > 0: + if (change.update) await this.updateRateLimitPolicies(change.update); + break; + default: + break; + } + }) + ); + } + + async createRateLimitPolicy(policy: RateLimitPolicy): Promise { + const created = await this.client.rateLimitPolicies.create(policy as any); + return created as RateLimitPolicy; + } + + async createRateLimitPolicies(creates: CalculatedChanges['create']): Promise { + await this.client.pool + .addEachTask({ + data: creates || [], + generator: (item: RateLimitPolicy) => + this.createRateLimitPolicy(item) + .then((data) => { + this.didCreate(data); + this.created += 1; + }) + .catch((err) => { + throw new Error(`Problem creating ${this.type} ${this.objString(item)}\n${err}`); + }), + }) + .promise(); + } + + async updateRateLimitPolicy(policy: RateLimitPolicy): Promise { + const { id, configuration } = policy; + + if (!id) { + throw new Error(`Missing id for ${this.type} ${this.objString(policy)}`); + } + + await this.client.rateLimitPolicies.update(id, { configuration }); + } + + async updateRateLimitPolicies(updates: CalculatedChanges['update']): Promise { + await this.client.pool + .addEachTask({ + data: updates || [], + generator: (item: RateLimitPolicy) => + this.updateRateLimitPolicy(item) + .then(() => { + this.didUpdate(item); + this.updated += 1; + }) + .catch((err) => { + throw new Error(`Problem updating ${this.type} ${this.objString(item)}\n${err}`); + }), + }) + .promise(); + } + + async deleteRateLimitPolicy(policy: RateLimitPolicy): Promise { + if (!policy.id) { + throw new Error(`Missing id for ${this.type} ${this.objString(policy)}`); + } + await this.client.rateLimitPolicies.delete(policy.id); + } + + async deleteRateLimitPolicies(data: Asset[]): Promise { + if ( + this.config('AUTH0_ALLOW_DELETE') === 'true' || + this.config('AUTH0_ALLOW_DELETE') === true + ) { + await this.client.pool + .addEachTask({ + data: data || [], + generator: (item: RateLimitPolicy) => + this.deleteRateLimitPolicy(item) + .then(() => { + this.didDelete(item); + this.deleted += 1; + }) + .catch((err) => { + throw new Error(`Problem deleting ${this.type} ${this.objString(item)}\n${err}`); + }), + }) + .promise(); + } else { + log.warn( + `Detected the following ${ + this.type + } should be deleted. Doing so may be destructive.\nYou can enable deletes by setting 'AUTH0_ALLOW_DELETE' to true in the config\n${data + .map((i) => this.objString(i as RateLimitPolicy)) + .join('\n')}` + ); + } + } +} diff --git a/src/tools/constants.ts b/src/tools/constants.ts index 3e8490340..d9260ff7c 100644 --- a/src/tools/constants.ts +++ b/src/tools/constants.ts @@ -222,6 +222,7 @@ const constants = { CONNECTION_PROFILES_DIRECTORY: 'connection-profiles', TOKEN_EXCHANGE_PROFILES_DIRECTORY: 'token-exchange-profiles', SUPPLEMENTAL_SIGNALS_DIRECTORY: 'supplemental-signals', + RATE_LIMIT_POLICIES_DIRECTORY: 'rate-limit-policies', }; export default constants; diff --git a/src/types.ts b/src/types.ts index 6efc84ee3..8e64a9351 100644 --- a/src/types.ts +++ b/src/types.ts @@ -21,6 +21,7 @@ import { AttackProtection } from './tools/auth0/handlers/attackProtection'; import { TokenExchangeProfile } from './tools/auth0/handlers/tokenExchangeProfiles'; import { RiskAssessment } from './tools/auth0/handlers/riskAssessment'; import { SupplementalSignals } from './tools/auth0/handlers/supplementalSignals'; +import { RateLimitPolicy } from './tools/auth0/handlers/rateLimitPolicies'; type SharedPaginationParams = { checkpoint?: boolean; @@ -164,6 +165,7 @@ export type Assets = Partial<{ userAttributeProfilesWithId: UserAttributeProfile[] | null; connectionProfiles: Asset[] | null; tokenExchangeProfiles: TokenExchangeProfile[] | null; + rateLimitPolicies: RateLimitPolicy[] | null; }>; export type CalculatedChanges = { @@ -229,7 +231,8 @@ export type AssetTypes = | 'userAttributeProfiles' | 'connectionProfiles' | 'tokenExchangeProfiles' - | 'supplementalSignals'; + | 'supplementalSignals' + | 'rateLimitPolicies'; export type KeywordMappings = { [key: string]: (string | number)[] | string | number }; diff --git a/test/context/directory/rateLimitPolicies.test.ts b/test/context/directory/rateLimitPolicies.test.ts new file mode 100644 index 000000000..9a8f0764e --- /dev/null +++ b/test/context/directory/rateLimitPolicies.test.ts @@ -0,0 +1,157 @@ +import path from 'path'; +import { expect } from 'chai'; +import fs from 'fs-extra'; +import { ManagementClient } from 'auth0'; + +import Context from '../../../src/context/directory'; +import handler from '../../../src/context/directory/handlers/rateLimitPolicies'; +import { constants } from '../../../src/tools'; +import { cleanThenMkdir, mockMgmtClient, testDataDir } from '../../utils'; +import { Config } from '../../../src/types'; + +describe('#directory context rateLimitPolicies', () => { + it('should process rateLimitPolicies', async () => { + const dir = path.join(testDataDir, 'directory', 'rateLimitPolicies-process'); + cleanThenMkdir(dir); + const rateLimitPoliciesDir = path.join(dir, constants.RATE_LIMIT_POLICIES_DIRECTORY); + cleanThenMkdir(rateLimitPoliciesDir); + + const policy1 = { + resource: 'oauth_authentication_api', + consumer: 'client', + consumer_selector: 'all-clients', + configuration: { + action: 'block', + limit: 100, + }, + }; + + const policy2 = { + resource: 'oauth_authentication_api', + consumer: 'client', + consumer_selector: 'my-client-id', + configuration: { + action: 'allow', + }, + }; + + fs.writeFileSync( + path.join(rateLimitPoliciesDir, 'all-clients.json'), + JSON.stringify(policy1, null, 2) + ); + fs.writeFileSync( + path.join(rateLimitPoliciesDir, 'my-client.json'), + JSON.stringify(policy2, null, 2) + ); + + const config = { AUTH0_INPUT_FILE: dir } as Config; + const context = new Context(config, mockMgmtClient() as unknown as ManagementClient); + await context.loadAssetsFromLocal(); + + expect(context.assets.rateLimitPolicies).to.be.an('array'); + expect(context.assets.rateLimitPolicies).to.have.lengthOf(2); + expect(context.assets.rateLimitPolicies).to.deep.include(policy1); + expect(context.assets.rateLimitPolicies).to.deep.include(policy2); + }); + + it('should process empty rateLimitPolicies directory', async () => { + const dir = path.join(testDataDir, 'directory', 'rateLimitPolicies-empty'); + cleanThenMkdir(dir); + const rateLimitPoliciesDir = path.join(dir, constants.RATE_LIMIT_POLICIES_DIRECTORY); + cleanThenMkdir(rateLimitPoliciesDir); + + const config = { AUTH0_INPUT_FILE: dir } as Config; + const context = new Context(config, mockMgmtClient() as unknown as ManagementClient); + await context.loadAssetsFromLocal(); + + expect(context.assets.rateLimitPolicies).to.be.an('array'); + expect(context.assets.rateLimitPolicies).to.have.lengthOf(0); + }); + + it('should skip if rateLimitPolicies directory does not exist', async () => { + const dir = path.join(testDataDir, 'directory', 'rateLimitPolicies-nonexistent'); + cleanThenMkdir(dir); + + const config = { AUTH0_INPUT_FILE: dir } as Config; + const context = new Context(config, mockMgmtClient() as unknown as ManagementClient); + await context.loadAssetsFromLocal(); + + expect(context.assets.rateLimitPolicies).to.equal(null); + }); + + it('should dump rateLimitPolicies', async () => { + const repoDir = path.join(testDataDir, 'directory', 'rateLimitPolicies-dump'); + cleanThenMkdir(repoDir); + const context = new Context( + { AUTH0_INPUT_FILE: repoDir } as Config, + mockMgmtClient() as unknown as ManagementClient + ); + + const rateLimitPolicies: any[] = [ + { + id: 'rlp_123', + resource: 'oauth_authentication_api', + consumer: 'client', + consumer_selector: 'all-clients', + configuration: { + action: 'block', + limit: 100, + }, + created_at: '2023-01-01T00:00:00.000Z', + updated_at: '2023-01-02T00:00:00.000Z', + }, + { + id: 'rlp_456', + resource: 'oauth_authentication_api', + consumer: 'client', + consumer_selector: 'my-client-id', + configuration: { + action: 'redirect', + limit: 50, + redirect_uri: 'https://example.com/blocked', + }, + created_at: '2023-01-03T00:00:00.000Z', + updated_at: '2023-01-04T00:00:00.000Z', + }, + ]; + + context.assets.rateLimitPolicies = rateLimitPolicies; + + await handler.dump(context); + + const rateLimitPoliciesDir = path.join(repoDir, constants.RATE_LIMIT_POLICIES_DIRECTORY); + + expect(fs.existsSync(rateLimitPoliciesDir)).to.equal(true); + + const allClientsFile = path.join(rateLimitPoliciesDir, 'all-clients.json'); + const myClientFile = path.join(rateLimitPoliciesDir, 'my-client-id.json'); + + expect(fs.existsSync(allClientsFile)).to.equal(true); + expect(fs.existsSync(myClientFile)).to.equal(true); + + const allClientsContent = JSON.parse(fs.readFileSync(allClientsFile, 'utf8')); + const myClientContent = JSON.parse(fs.readFileSync(myClientFile, 'utf8')); + + // Verify id, created_at and updated_at were removed from both files + expect(allClientsContent).to.not.have.property('id'); + expect(allClientsContent).to.not.have.property('created_at'); + expect(allClientsContent).to.not.have.property('updated_at'); + expect(myClientContent).to.not.have.property('id'); + expect(myClientContent).to.not.have.property('created_at'); + expect(myClientContent).to.not.have.property('updated_at'); + + // Verify other properties were preserved + expect(allClientsContent.resource).to.equal('oauth_authentication_api'); + expect(allClientsContent.consumer).to.equal('client'); + expect(allClientsContent.consumer_selector).to.equal('all-clients'); + expect(allClientsContent.configuration.action).to.equal('block'); + expect(allClientsContent.configuration.limit).to.equal(100); + + expect(myClientContent.resource).to.equal('oauth_authentication_api'); + expect(myClientContent.consumer).to.equal('client'); + expect(myClientContent.consumer_selector).to.equal('my-client-id'); + expect(myClientContent.configuration.action).to.equal('redirect'); + expect(myClientContent.configuration.limit).to.equal(50); + expect(myClientContent.configuration.redirect_uri).to.equal('https://example.com/blocked'); + }); +}); diff --git a/test/context/yaml/context.test.js b/test/context/yaml/context.test.js index 7ac798e60..a42e90b51 100644 --- a/test/context/yaml/context.test.js +++ b/test/context/yaml/context.test.js @@ -340,6 +340,7 @@ describe('#YAML context validation', () => { supplementalSignals: { akamai_enabled: false, }, + rateLimitPolicies: [], tokenExchangeProfiles: [], userAttributeProfiles: [], phoneTemplates: [], @@ -483,6 +484,7 @@ describe('#YAML context validation', () => { supplementalSignals: { akamai_enabled: false, }, + rateLimitPolicies: [], tokenExchangeProfiles: [], userAttributeProfiles: [], phoneTemplates: [], @@ -627,6 +629,7 @@ describe('#YAML context validation', () => { supplementalSignals: { akamai_enabled: false, }, + rateLimitPolicies: [], tokenExchangeProfiles: [], userAttributeProfiles: [], phoneTemplates: [], diff --git a/test/context/yaml/rateLimitPolicies.test.ts b/test/context/yaml/rateLimitPolicies.test.ts new file mode 100644 index 000000000..9a11653cc --- /dev/null +++ b/test/context/yaml/rateLimitPolicies.test.ts @@ -0,0 +1,111 @@ +import path from 'path'; +import fs from 'fs-extra'; +import { expect } from 'chai'; +import { cloneDeep } from 'lodash'; +import { ManagementClient } from 'auth0'; + +import Context from '../../../src/context/yaml'; +import handler from '../../../src/context/yaml/handlers/rateLimitPolicies'; +import { cleanThenMkdir, testDataDir, mockMgmtClient } from '../../utils'; +import { Config } from '../../../src/types'; + +describe('#YAML context rateLimitPolicies', () => { + it('should process rateLimitPolicies', async () => { + const dir = path.join(testDataDir, 'yaml', 'rateLimitPolicies'); + cleanThenMkdir(dir); + + const yaml = ` + rateLimitPolicies: + - + resource: 'oauth_authentication_api' + consumer: 'client' + consumer_selector: 'all-clients' + configuration: + action: 'block' + limit: 100 + - + resource: 'oauth_authentication_api' + consumer: 'client' + consumer_selector: 'my-client-id' + configuration: + action: 'allow' + `; + + const target = [ + { + resource: 'oauth_authentication_api', + consumer: 'client', + consumer_selector: 'all-clients', + configuration: { + action: 'block', + limit: 100, + }, + }, + { + resource: 'oauth_authentication_api', + consumer: 'client', + consumer_selector: 'my-client-id', + configuration: { + action: 'allow', + }, + }, + ]; + + const yamlFile = path.join(dir, 'rateLimitPolicies.yaml'); + fs.writeFileSync(yamlFile, yaml); + + const config = { AUTH0_INPUT_FILE: yamlFile } as Config; + const context = new Context(config, mockMgmtClient() as unknown as ManagementClient); + await context.loadAssetsFromLocal(); + expect(context.assets.rateLimitPolicies).to.deep.equal(target); + }); + + it('should dump rateLimitPolicies', async () => { + const dir = path.join(testDataDir, 'yaml', 'rateLimitPolicies'); + cleanThenMkdir(dir); + const context = new Context( + { AUTH0_INPUT_FILE: path.join(dir, './rateLimitPolicies.yml') } as Config, + mockMgmtClient() as unknown as ManagementClient + ); + + const rateLimitPolicies: any[] = [ + { + id: 'rlp_123', + resource: 'oauth_authentication_api', + consumer: 'client', + consumer_selector: 'all-clients', + configuration: { + action: 'block', + limit: 100, + }, + created_at: '2023-01-01T00:00:00.000Z', + updated_at: '2023-01-02T00:00:00.000Z', + }, + { + id: 'rlp_456', + resource: 'oauth_authentication_api', + consumer: 'client', + consumer_selector: 'my-client-id', + configuration: { + action: 'redirect', + limit: 50, + redirect_uri: 'https://example.com/blocked', + }, + created_at: '2023-01-03T00:00:00.000Z', + updated_at: '2023-01-04T00:00:00.000Z', + }, + ]; + + context.assets.rateLimitPolicies = cloneDeep(rateLimitPolicies); + + const dumped = await handler.dump(context); + + // Create a copy without the fields that should be stripped during dump + const expectedRateLimitPolicies = cloneDeep(rateLimitPolicies).map((policy) => { + const { id: _id, created_at: _createdAt, updated_at: _updatedAt, ...rest } = policy; + return rest; + }); + + expect(dumped).to.deep.equal({ rateLimitPolicies: expectedRateLimitPolicies }); + }); +}); diff --git a/test/tools/auth0/handlers/rateLimitPolicies.test.ts b/test/tools/auth0/handlers/rateLimitPolicies.test.ts new file mode 100644 index 000000000..64b589b4e --- /dev/null +++ b/test/tools/auth0/handlers/rateLimitPolicies.test.ts @@ -0,0 +1,258 @@ +import { PromisePoolExecutor } from 'promise-pool-executor'; +import { expect } from 'chai'; + +import RateLimitPoliciesHandler from '../../../../src/tools/auth0/handlers/rateLimitPolicies'; +import pageClient from '../../../../src/tools/auth0/client'; +import { mockPagedData } from '../../../utils'; + +const pool = new PromisePoolExecutor({ + concurrencyLimit: 3, + frequencyLimit: 1000, + frequencyWindow: 1000, // 1 sec +}); + +const sampleRateLimitPolicy = { + id: 'rlp_123', + resource: 'oauth_authentication_api', + consumer: 'client', + consumer_selector: '*', + configuration: { + action: 'block', + limit: 100, + }, +}; + +describe('#rateLimitPolicies handler', () => { + const config = function (key) { + return config.data && config.data[key]; + }; + + config.data = { + AUTH0_ALLOW_DELETE: false, + }; + + describe('#rateLimitPolicies validate', () => { + it('should pass validation', async () => { + const handler = new RateLimitPoliciesHandler({ client: {}, config } as any); + const stageFn = Object.getPrototypeOf(handler).validate; + const data = [ + { + resource: 'oauth_authentication_api', + consumer: 'client', + consumer_selector: '*', + configuration: { + action: 'block', + limit: 100, + }, + }, + ]; + + await stageFn.apply(handler, [{ rateLimitPolicies: data }]); + }); + }); + + describe('#rateLimitPolicies process', () => { + it('should return empty if no rateLimitPolicies asset', async () => { + const auth0 = { + rateLimitPolicies: {}, + pool, + }; + + const handler = new RateLimitPoliciesHandler({ + client: pageClient(auth0 as any), + config, + } as any); + const stageFn = Object.getPrototypeOf(handler).processChanges; + const response = await stageFn.apply(handler, [{}]); + expect(response).to.equal(undefined); + }); + + it('should create rateLimitPolicies', async () => { + const auth0 = { + rateLimitPolicies: { + create: function (data) { + (() => expect(this).to.not.be.undefined)(); + expect(data).to.be.an('object'); + expect(data.consumer_selector).to.equal(sampleRateLimitPolicy.consumer_selector); + return Promise.resolve(data); + }, + list: (params) => mockPagedData(params, 'rate_limit_policies', []), + }, + pool, + }; + + const handler = new RateLimitPoliciesHandler({ + client: pageClient(auth0 as any), + config, + } as any); + const stageFn = Object.getPrototypeOf(handler).processChanges; + + await stageFn.apply(handler, [ + { + rateLimitPolicies: [sampleRateLimitPolicy], + }, + ]); + }); + + it('should get rateLimitPolicies', async () => { + const auth0 = { + rateLimitPolicies: { + list: (params) => mockPagedData(params, 'rate_limit_policies', [sampleRateLimitPolicy]), + }, + pool, + }; + + const handler = new RateLimitPoliciesHandler({ + client: pageClient(auth0 as any), + config, + } as any); + const data = await handler.getType(); + expect(data).to.deep.equal([sampleRateLimitPolicy]); + }); + + it('should handle 403 error when rate limit policies feature is not enabled', async () => { + const auth0 = { + rateLimitPolicies: { + list: () => Promise.reject(Object.assign(new Error('Forbidden'), { statusCode: 403 })), + }, + }; + + const handler = new RateLimitPoliciesHandler({ + client: pageClient(auth0 as any), + config, + } as any); + const data = await handler.getType(); + expect(data).to.equal(null); + }); + + it('should update rateLimitPolicies sending only configuration', async () => { + const auth0 = { + rateLimitPolicies: { + update: function (id, data) { + (() => expect(this).to.not.be.undefined)(); + expect(id).to.be.a('string'); + expect(id).to.equal(sampleRateLimitPolicy.id); + // PATCH should only contain configuration + expect(data).to.deep.equal({ configuration: sampleRateLimitPolicy.configuration }); + expect(data).to.not.have.property('resource'); + expect(data).to.not.have.property('consumer'); + expect(data).to.not.have.property('consumer_selector'); + return Promise.resolve(data); + }, + list: (params) => mockPagedData(params, 'rate_limit_policies', [sampleRateLimitPolicy]), + }, + pool, + }; + + const handler = new RateLimitPoliciesHandler({ + client: pageClient(auth0 as any), + config, + } as any); + const stageFn = Object.getPrototypeOf(handler).processChanges; + + await stageFn.apply(handler, [ + { + rateLimitPolicies: [sampleRateLimitPolicy], + }, + ]); + }); + + it('should delete rateLimitPolicies and create another one instead', async () => { + config.data.AUTH0_ALLOW_DELETE = true; + + const newPolicy = { + resource: 'oauth_authentication_api', + consumer: 'client', + consumer_selector: 'some-client-id', + configuration: { + action: 'allow', + }, + }; + + const auth0 = { + rateLimitPolicies: { + create: function (data) { + (() => expect(this).to.not.be.undefined)(); + expect(data).to.be.an('object'); + expect(data.consumer_selector).to.equal(newPolicy.consumer_selector); + return Promise.resolve(data); + }, + delete: function (id) { + (() => expect(this).to.not.be.undefined)(); + expect(id).to.be.a('string'); + expect(id).to.equal(sampleRateLimitPolicy.id); + return Promise.resolve([]); + }, + update: function (id, data) { + (() => expect(this).to.not.be.undefined)(); + expect(id).to.be.a('string'); + expect(data).to.be.an('object'); + return Promise.resolve(data); + }, + list: (params) => mockPagedData(params, 'rate_limit_policies', [sampleRateLimitPolicy]), + }, + pool, + }; + + const handler = new RateLimitPoliciesHandler({ + client: pageClient(auth0 as any), + config, + } as any); + const stageFn = Object.getPrototypeOf(handler).processChanges; + + await stageFn.apply(handler, [{ rateLimitPolicies: [newPolicy] }]); + }); + + it('should not remove rateLimitPolicies if it is not allowed by config', async () => { + config.data.AUTH0_ALLOW_DELETE = false; + let isDeleteCalled = false; + const auth0 = { + rateLimitPolicies: { + delete: (id) => { + isDeleteCalled = true; + expect(id).to.be.an('undefined'); + return Promise.resolve([]); + }, + list: (params) => mockPagedData(params, 'rate_limit_policies', [sampleRateLimitPolicy]), + }, + pool, + }; + + const handler = new RateLimitPoliciesHandler({ + client: pageClient(auth0 as any), + config, + } as any); + const stageFn = Object.getPrototypeOf(handler).processChanges; + + await stageFn.apply(handler, [{ rateLimitPolicies: [] }]); + expect(isDeleteCalled).to.equal(false); + }); + + it('should delete all rateLimitPolicies', async () => { + config.data.AUTH0_ALLOW_DELETE = true; + let removed = false; + const auth0 = { + rateLimitPolicies: { + delete: function (id) { + removed = true; + (() => expect(this).to.not.be.undefined)(); + expect(id).to.be.a('string'); + expect(id).to.equal(sampleRateLimitPolicy.id); + return Promise.resolve([]); + }, + list: (params) => mockPagedData(params, 'rate_limit_policies', [sampleRateLimitPolicy]), + }, + pool, + }; + + const handler = new RateLimitPoliciesHandler({ + client: pageClient(auth0 as any), + config, + } as any); + const stageFn = Object.getPrototypeOf(handler).processChanges; + + await stageFn.apply(handler, [{ rateLimitPolicies: [] }]); + expect(removed).to.equal(true); + }); + }); +}); diff --git a/test/utils.js b/test/utils.js index da4926bbf..d3bac1c44 100644 --- a/test/utils.js +++ b/test/utils.js @@ -246,6 +246,9 @@ export function mockMgmtClient() { supplementalSignals: { get: () => Promise.resolve({ akamai_enabled: false }), }, + rateLimitPolicies: { + list: (params) => mockPagedData(params, 'rate_limit_policies', []), + }, }; } From 47fa940d8522f752b4bcd57972d64dd1c24362a8 Mon Sep 17 00:00:00 2001 From: "ankitatripathi.mp@gmail.com" Date: Fri, 29 May 2026 12:33:52 +0530 Subject: [PATCH 2/3] upgrade node-auth0 to latest --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index aebbc2e47..f430a320c 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "@clack/prompts": "1.3.0", "ajv": "^6.12.6", "chalk": "5.6.2", - "auth0": "^5.9.0", + "auth0": "^5.11.0", "dot-prop": "^5.3.0", "fs-extra": "^10.1.0", "js-yaml": "^4.1.1", From 79e2a33af4338cd04bc590800ac117fc117f5c22 Mon Sep 17 00:00:00 2001 From: "ankitatripathi.mp@gmail.com" Date: Fri, 29 May 2026 13:58:22 +0530 Subject: [PATCH 3/3] test: add rate-limit-policies 403 mock to e2e recordings --- ...-resources-if-AUTH0_ALLOW_DELETE-is-true.json | 16 ++++++++++++++++ ...resources-if-AUTH0_ALLOW_DELETE-is-false.json | 16 ++++++++++++++++ ...ump-and-deploy-without-throwing-an-error.json | 16 ++++++++++++++++ .../should-dump-without-throwing-an-error.json | 16 ++++++++++++++++ 4 files changed, 64 insertions(+) diff --git a/test/e2e/recordings/should-deploy-while-deleting-resources-if-AUTH0_ALLOW_DELETE-is-true.json b/test/e2e/recordings/should-deploy-while-deleting-resources-if-AUTH0_ALLOW_DELETE-is-true.json index 5dd16886e..85e8ef8a9 100644 --- a/test/e2e/recordings/should-deploy-while-deleting-resources-if-AUTH0_ALLOW_DELETE-is-true.json +++ b/test/e2e/recordings/should-deploy-while-deleting-resources-if-AUTH0_ALLOW_DELETE-is-true.json @@ -17710,4 +17710,20 @@ "rawHeaders": [], "responseIsBinary": false } +, + { + "scope": "https://deploy-cli-dev.eu.auth0.com:443", + "method": "GET", + "path": "/api/v2/rate-limit-policies?take=50", + "body": "", + "status": 403, + "response": { + "statusCode": 403, + "error": "Forbidden", + "message": "Rate Limit Policies feature is not enabled for this tenant.", + "errorCode": "insufficient_scope" + }, + "rawHeaders": [], + "responseIsBinary": false + } ] \ No newline at end of file diff --git a/test/e2e/recordings/should-deploy-without-deleting-resources-if-AUTH0_ALLOW_DELETE-is-false.json b/test/e2e/recordings/should-deploy-without-deleting-resources-if-AUTH0_ALLOW_DELETE-is-false.json index c9c9b1ae2..f43daad8a 100644 --- a/test/e2e/recordings/should-deploy-without-deleting-resources-if-AUTH0_ALLOW_DELETE-is-false.json +++ b/test/e2e/recordings/should-deploy-without-deleting-resources-if-AUTH0_ALLOW_DELETE-is-false.json @@ -21260,4 +21260,20 @@ "rawHeaders": [], "responseIsBinary": false } +, + { + "scope": "https://deploy-cli-dev.eu.auth0.com:443", + "method": "GET", + "path": "/api/v2/rate-limit-policies?take=50", + "body": "", + "status": 403, + "response": { + "statusCode": 403, + "error": "Forbidden", + "message": "Rate Limit Policies feature is not enabled for this tenant.", + "errorCode": "insufficient_scope" + }, + "rawHeaders": [], + "responseIsBinary": false + } ] \ No newline at end of file diff --git a/test/e2e/recordings/should-dump-and-deploy-without-throwing-an-error.json b/test/e2e/recordings/should-dump-and-deploy-without-throwing-an-error.json index 631d12dc0..83ab24218 100644 --- a/test/e2e/recordings/should-dump-and-deploy-without-throwing-an-error.json +++ b/test/e2e/recordings/should-dump-and-deploy-without-throwing-an-error.json @@ -9409,4 +9409,20 @@ "rawHeaders": [], "responseIsBinary": false } +, + { + "scope": "https://deploy-cli-dev.eu.auth0.com:443", + "method": "GET", + "path": "/api/v2/rate-limit-policies?take=50", + "body": "", + "status": 403, + "response": { + "statusCode": 403, + "error": "Forbidden", + "message": "Rate Limit Policies feature is not enabled for this tenant.", + "errorCode": "insufficient_scope" + }, + "rawHeaders": [], + "responseIsBinary": false + } ] \ No newline at end of file diff --git a/test/e2e/recordings/should-dump-without-throwing-an-error.json b/test/e2e/recordings/should-dump-without-throwing-an-error.json index 27718b977..ff192f751 100644 --- a/test/e2e/recordings/should-dump-without-throwing-an-error.json +++ b/test/e2e/recordings/should-dump-without-throwing-an-error.json @@ -3965,4 +3965,20 @@ "rawHeaders": [], "responseIsBinary": false } +, + { + "scope": "https://deploy-cli-dev.eu.auth0.com:443", + "method": "GET", + "path": "/api/v2/rate-limit-policies?take=50", + "body": "", + "status": 403, + "response": { + "statusCode": 403, + "error": "Forbidden", + "message": "Rate Limit Policies feature is not enabled for this tenant.", + "errorCode": "insufficient_scope" + }, + "rawHeaders": [], + "responseIsBinary": false + } ] \ No newline at end of file