diff --git a/docs/resource-specific-documentation.md b/docs/resource-specific-documentation.md index 8abfdb3c..9a6ef342 100644 --- a/docs/resource-specific-documentation.md +++ b/docs/resource-specific-documentation.md @@ -576,6 +576,30 @@ phoneProviders: ] ``` +## Risk Assessments + +Risk assessments configuration allows you to enable or disable risk assessment features for your tenant. + +### YAML Example + +```yaml +# Contents of ./tenant.yaml +riskAssessment: + enabled: true +``` + +### Directory Example + +File: `./risk-assessment/settings.json` + +```json +{ + "enabled": true +} +``` + +For more details, see the [Management API documentation](https://auth0.com/docs/api/management/v2#!/Risk_Assessments/get_settings). + ## Connection Profiles Application specific configuration for use with the OIN Express Configuration feature diff --git a/examples/directory/risk-assessments/settings.json b/examples/directory/risk-assessments/settings.json new file mode 100644 index 00000000..010732bf --- /dev/null +++ b/examples/directory/risk-assessments/settings.json @@ -0,0 +1,3 @@ +{ + "enabled": false +} diff --git a/examples/yaml/tenant.yaml b/examples/yaml/tenant.yaml index 9c68d7df..d41c3093 100644 --- a/examples/yaml/tenant.yaml +++ b/examples/yaml/tenant.yaml @@ -419,3 +419,8 @@ userAttributeProfiles: type: "email" required: true +riskAssessment: + enabled: false + # newDevice: + # remember_for: 30 + diff --git a/src/context/directory/handlers/index.ts b/src/context/directory/handlers/index.ts index e1107ff5..8d9cc64c 100644 --- a/src/context/directory/handlers/index.ts +++ b/src/context/directory/handlers/index.ts @@ -18,6 +18,7 @@ import actions from './actions'; import organizations from './organizations'; import triggers from './triggers'; import attackProtection from './attackProtection'; +import riskAssessment from './riskAssessment'; import branding from './branding'; import phoneProviders from './phoneProvider'; import logStreams from './logStreams'; @@ -69,6 +70,7 @@ const directoryHandlers: { organizations, triggers, attackProtection, + riskAssessment, branding, phoneProviders, logStreams, diff --git a/src/context/directory/handlers/riskAssessment.ts b/src/context/directory/handlers/riskAssessment.ts new file mode 100644 index 00000000..3fbbb120 --- /dev/null +++ b/src/context/directory/handlers/riskAssessment.ts @@ -0,0 +1,56 @@ +import path from 'path'; +import fs from 'fs-extra'; +import { constants } from '../../../tools'; +import { dumpJSON, existsMustBeDir, isFile, loadJSON } from '../../../utils'; +import { DirectoryHandler } from '.'; +import DirectoryContext from '..'; +import { ParsedAsset, Asset } from '../../../types'; + +type ParsedRiskAssessment = ParsedAsset<'riskAssessment', Asset>; + +function parse(context: DirectoryContext): ParsedRiskAssessment { + const riskAssessmentDirectory = path.join( + context.filePath, + constants.RISK_ASSESSMENT_DIRECTORY + ); + const riskAssessmentFile = path.join(riskAssessmentDirectory, 'settings.json'); + + if (!existsMustBeDir(riskAssessmentDirectory)) { + return { riskAssessment: null }; + } + + if (!isFile(riskAssessmentFile)) { + return { riskAssessment: null }; + } + + const riskAssessment = loadJSON(riskAssessmentFile, { + mappings: context.mappings, + disableKeywordReplacement: context.disableKeywordReplacement, + }); + + return { + riskAssessment, + }; +} + +async function dump(context: DirectoryContext): Promise { + const { riskAssessment } = context.assets; + + if (!riskAssessment) return; + + const riskAssessmentDirectory = path.join( + context.filePath, + constants.RISK_ASSESSMENT_DIRECTORY + ); + const riskAssessmentFile = path.join(riskAssessmentDirectory, 'settings.json'); + + fs.ensureDirSync(riskAssessmentDirectory); + dumpJSON(riskAssessmentFile, riskAssessment); +} + +const riskAssessmentHandler: DirectoryHandler = { + parse, + dump, +}; + +export default riskAssessmentHandler; diff --git a/src/context/yaml/handlers/index.ts b/src/context/yaml/handlers/index.ts index 46c60a79..b3f050ed 100644 --- a/src/context/yaml/handlers/index.ts +++ b/src/context/yaml/handlers/index.ts @@ -18,6 +18,7 @@ import organizations from './organizations'; import actions from './actions'; import triggers from './triggers'; import attackProtection from './attackProtection'; +import riskAssessment from './riskAssessment'; import branding from './branding'; import phoneProviders from './phoneProvider'; import logStreams from './logStreams'; @@ -67,6 +68,7 @@ const yamlHandlers: { [key in AssetTypes]: YAMLHandler<{ [key: string]: unknown organizations, triggers, attackProtection, + riskAssessment, branding, phoneProviders, logStreams, diff --git a/src/context/yaml/handlers/riskAssessment.ts b/src/context/yaml/handlers/riskAssessment.ts new file mode 100644 index 00000000..52231e89 --- /dev/null +++ b/src/context/yaml/handlers/riskAssessment.ts @@ -0,0 +1,32 @@ +import { YAMLHandler } from '.'; +import YAMLContext from '..'; +import { Asset, ParsedAsset } from '../../../types'; + +type ParsedRiskAssessment = ParsedAsset<'riskAssessment', Asset>; + +async function parse(context: YAMLContext): Promise { + const { riskAssessment } = context.assets; + + if (!riskAssessment) return { riskAssessment: null }; + + return { + riskAssessment, + }; +} + +async function dump(context: YAMLContext): Promise { + const { riskAssessment } = context.assets; + + if (!riskAssessment) return { riskAssessment: null }; + + return { + riskAssessment, + }; +} + +const riskAssessmentHandler: YAMLHandler = { + parse, + dump, +}; + +export default riskAssessmentHandler; diff --git a/src/tools/auth0/handlers/index.ts b/src/tools/auth0/handlers/index.ts index cb133bbc..69c5c846 100644 --- a/src/tools/auth0/handlers/index.ts +++ b/src/tools/auth0/handlers/index.ts @@ -24,6 +24,7 @@ import * as actions from './actions'; import * as triggers from './triggers'; import * as organizations from './organizations'; import * as attackProtection from './attackProtection'; +import * as riskAssessment from './riskAssessment'; import * as logStreams from './logStreams'; import * as customDomains from './customDomains'; import * as themes from './themes'; @@ -66,6 +67,7 @@ const auth0ApiHandlers: { [key in AssetTypes]: any } = { triggers, organizations, attackProtection, + riskAssessment, logStreams, customDomains, themes, diff --git a/src/tools/auth0/handlers/riskAssessment.ts b/src/tools/auth0/handlers/riskAssessment.ts new file mode 100644 index 00000000..8c50038c --- /dev/null +++ b/src/tools/auth0/handlers/riskAssessment.ts @@ -0,0 +1,99 @@ +import DefaultAPIHandler from './default'; +import { Assets } from '../../../types'; + +export const schema = { + type: 'object', + properties: { + enabled: { + type: 'boolean', + description: 'Whether or not risk assessment is enabled.', + }, + newDevice: { + type: 'object', + properties: { + remember_for: { + type: 'number', + description: 'Length of time to remember devices for, in days.', + }, + }, + required: ['remember_for'], + }, + }, + required: ['enabled'], +}; + +export type RiskAssessmentsSettings = { + enabled: boolean; + newDevice?: { + remember_for: number; + }; +}; + +export default class RiskAssessmentsHandler extends DefaultAPIHandler { + existing: RiskAssessmentsSettings | null; + + constructor(config: DefaultAPIHandler) { + super({ + ...config, + type: 'riskAssessment', + }); + } + + async getType(): Promise { + if (this.existing) { + return this.existing; + } + + try { + const [settings, newDeviceSettings] = await Promise.all([ + this.client.riskAssessments.getSettings(), + this.client.riskAssessments.getNewDeviceSettings().catch((err) => { + if (err?.statusCode === 404) return { data: { remember_for: 0 } }; + throw err; + }), + ]); + + this.existing = { + enabled: settings.data.enabled, + ...(newDeviceSettings.data.remember_for > 0 && { + newDevice: { + remember_for: newDeviceSettings.data.remember_for, + }, + }), + }; + return this.existing; + } catch (err) { + if (err.statusCode === 404) return { enabled: false }; + throw err; + } + } + + async processChanges(assets: Assets): Promise { + const { riskAssessment } = assets; + + // Non-existing section means it doesn't need to be processed + if (!riskAssessment) { + return; + } + + const updates: Promise[] = []; + + // Update main settings (enabled flag) + const settings = { + enabled: riskAssessment.enabled as boolean, + }; + updates.push(this.client.riskAssessments.updateSettings(settings)); + + // Update new device settings if provided + if (riskAssessment.newDevice) { + const newDeviceSettings = { + remember_for: riskAssessment.newDevice.remember_for as number, + }; + updates.push(this.client.riskAssessments.updateNewDeviceSettings(newDeviceSettings)); + } + + await Promise.all(updates); + this.updated += 1; + this.didUpdate(riskAssessment); + } +} diff --git a/src/tools/constants.ts b/src/tools/constants.ts index b15ec552..6eba12f4 100644 --- a/src/tools/constants.ts +++ b/src/tools/constants.ts @@ -105,6 +105,7 @@ const constants = { CONNECTIONS_ID_NAME: 'id', ROLES_DIRECTORY: 'roles', ATTACK_PROTECTION_DIRECTORY: 'attack-protection', + RISK_ASSESSMENT_DIRECTORY: 'risk-assessment', GUARDIAN_FACTORS: [ 'sms', 'push-notification', diff --git a/src/types.ts b/src/types.ts index e1081a35..a93ffdf0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -97,6 +97,7 @@ export type Asset = { [key: string]: any }; export type Assets = Partial<{ actions: Action[] | null; attackProtection: AttackProtection | null; + riskAssessment: Asset | null; branding: | (Asset & { templates?: { template: string; body: string }[] | null; @@ -178,6 +179,7 @@ export type AssetTypes = | 'organizations' | 'triggers' | 'attackProtection' + | 'riskAssessment' | 'branding' | 'phoneProviders' | 'logStreams' diff --git a/test/context/directory/riskAssessment.test.js b/test/context/directory/riskAssessment.test.js new file mode 100644 index 00000000..5e17bd41 --- /dev/null +++ b/test/context/directory/riskAssessment.test.js @@ -0,0 +1,133 @@ +import path from 'path'; +import { expect } from 'chai'; +import Context from '../../../src/context/directory'; +import { cleanThenMkdir, createDir, mockMgmtClient, testDataDir } from '../../utils'; +import handler from '../../../src/context/directory/handlers/riskAssessment'; +import { loadJSON } from '../../../src/utils'; + +describe('#directory context risk-assessments', () => { + it('should process risk-assessments with newDevice from settings.json', async () => { + const files = { + 'risk-assessment': { + 'settings.json': '{"enabled": true, "newDevice": {"remember_for": 30}}', + }, + }; + + const repoDir = path.join(testDataDir, 'directory', 'riskAssessment1'); + createDir(repoDir, files); + + const config = { AUTH0_INPUT_FILE: repoDir }; + const context = new Context(config, mockMgmtClient()); + await context.loadAssetsFromLocal(); + + const target = { + enabled: true, + newDevice: { + remember_for: 30, + }, + }; + + expect(context.assets.riskAssessment).to.deep.equal(target); + }); + + it('should replace keywords in newDevice settings', async () => { + const files = { + 'risk-assessment': { + 'settings.json': '{"enabled": true, "newDevice": {"remember_for": @@REMEMBER_FOR_DAYS@@}}', + }, + }; + + const repoDir = path.join(testDataDir, 'directory', 'riskAssessment3'); + createDir(repoDir, files); + + const config = { + AUTH0_INPUT_FILE: repoDir, + AUTH0_KEYWORD_REPLACE_MAPPINGS: { + REMEMBER_FOR_DAYS: 60, + }, + }; + + const context = new Context(config, mockMgmtClient()); + await context.loadAssetsFromLocal(); + + const target = { + enabled: true, + newDevice: { + remember_for: 60, + }, + }; + + expect(context.assets.riskAssessment).to.deep.equal(target); + }); + + it('should process risk-assessments without newDevice', async () => { + const files = { + 'risk-assessment': { + 'settings.json': '{"enabled": false}', + }, + }; + + const repoDir = path.join(testDataDir, 'directory', 'riskAssessment4'); + createDir(repoDir, files); + + const config = { AUTH0_INPUT_FILE: repoDir }; + const context = new Context(config, mockMgmtClient()); + await context.loadAssetsFromLocal(); + + const target = { + enabled: false, + }; + + expect(context.assets.riskAssessment).to.deep.equal(target); + }); + + it('should dump risk-assessments with newDevice to settings.json', async () => { + const dir = path.join(testDataDir, 'directory', 'riskAssessmentDump'); + cleanThenMkdir(dir); + const context = new Context({ AUTH0_INPUT_FILE: dir }, mockMgmtClient()); + + context.assets.riskAssessment = { + enabled: true, + newDevice: { + remember_for: 90, + }, + }; + + await handler.dump(context); + const riskAssessmentFolder = path.join(dir, 'risk-assessment'); + + expect(loadJSON(path.join(riskAssessmentFolder, 'settings.json'))).to.deep.equal( + context.assets.riskAssessment + ); + }); + + it('should dump risk-assessments without newDevice', async () => { + const dir = path.join(testDataDir, 'directory', 'riskAssessmentDump2'); + cleanThenMkdir(dir); + const context = new Context({ AUTH0_INPUT_FILE: dir }, mockMgmtClient()); + + context.assets.riskAssessment = { + enabled: false, + }; + + await handler.dump(context); + const riskAssessmentFolder = path.join(dir, 'risk-assessment'); + + expect(loadJSON(path.join(riskAssessmentFolder, 'settings.json'))).to.deep.equal( + context.assets.riskAssessment + ); + }); + + it('should not create files if riskAssessment is null', async () => { + const dir = path.join(testDataDir, 'directory', 'riskAssessmentNull'); + cleanThenMkdir(dir); + const context = new Context({ AUTH0_INPUT_FILE: dir }, mockMgmtClient()); + + context.assets.riskAssessment = null; + + await handler.dump(context); + const riskAssessmentFolder = path.join(dir, 'risk-assessment'); + + expect(() => loadJSON(path.join(riskAssessmentFolder, 'settings.json'))).to.throw(); + }); +}); diff --git a/test/context/yaml/context.test.js b/test/context/yaml/context.test.js index 8fee4b39..f6e4cac5 100644 --- a/test/context/yaml/context.test.js +++ b/test/context/yaml/context.test.js @@ -272,6 +272,12 @@ describe('#YAML context validation', () => { guardianPhoneFactorSelectedProvider: { provider: 'twilio' }, guardianPolicies: { policies: [] }, resourceServers: [], + riskAssessment: { + enabled: false, + newDevice: { + remember_for: 30, + }, + }, rules: [], hooks: [], actions: [], @@ -402,6 +408,12 @@ describe('#YAML context validation', () => { guardianPhoneFactorSelectedProvider: { provider: 'twilio' }, guardianPolicies: { policies: [] }, resourceServers: [], + riskAssessment: { + enabled: false, + newDevice: { + remember_for: 30, + }, + }, rules: [], hooks: [], actions: [], @@ -533,6 +545,12 @@ describe('#YAML context validation', () => { guardianPhoneFactorSelectedProvider: { provider: 'twilio' }, guardianPolicies: { policies: [] }, resourceServers: [], + riskAssessment: { + enabled: false, + newDevice: { + remember_for: 30, + }, + }, rules: [], hooks: [], actions: [], diff --git a/test/tools/auth0/handlers/riskAssessment.tests.js b/test/tools/auth0/handlers/riskAssessment.tests.js new file mode 100644 index 00000000..12da0067 --- /dev/null +++ b/test/tools/auth0/handlers/riskAssessment.tests.js @@ -0,0 +1,154 @@ +const { expect } = require('chai'); +const riskAssessment = require('../../../../src/tools/auth0/handlers/riskAssessment'); + +describe('#riskAssessment handler', () => { + describe('#riskAssessment getType', () => { + it('should get risk assessments settings', async () => { + const auth0 = { + riskAssessments: { + getSettings: () => Promise.resolve({ data: { enabled: true } }), + getNewDeviceSettings: () => Promise.resolve({ data: { remember_for: 30 } }), + }, + }; + + const handler = new riskAssessment.default({ client: auth0 }); + const data = await handler.getType(); + expect(data).to.deep.equal({ enabled: true, newDevice: { remember_for: 30 } }); + }); + + it('should get risk assessments settings without newDevice when remember_for is 0', async () => { + const auth0 = { + riskAssessments: { + getSettings: () => Promise.resolve({ data: { enabled: true } }), + getNewDeviceSettings: () => Promise.resolve({ data: { remember_for: 0 } }), + }, + }; + + const handler = new riskAssessment.default({ client: auth0 }); + const data = await handler.getType(); + expect(data).to.deep.equal({ enabled: true }); + }); + + it('should return default settings when not found', async () => { + const auth0 = { + riskAssessments: { + getSettings: () => { + const error = new Error('Not found'); + error.statusCode = 404; + return Promise.reject(error); + }, + getNewDeviceSettings: () => { + const error = new Error('Not found'); + error.statusCode = 404; + return Promise.reject(error); + }, + }, + }; + + const handler = new riskAssessment.default({ client: auth0 }); + const data = await handler.getType(); + expect(data).to.deep.equal({ enabled: false }); + }); + }); + + describe('#riskAssessment processChanges', () => { + it('should update risk assessments settings to enabled', async () => { + const auth0 = { + riskAssessments: { + updateSettings: (data) => { + expect(data).to.be.an('object'); + expect(data.enabled).to.equal(true); + return Promise.resolve({ data }); + }, + }, + }; + + const handler = new riskAssessment.default({ client: auth0 }); + const stageFn = Object.getPrototypeOf(handler).processChanges; + + await stageFn.apply(handler, [{ riskAssessment: { enabled: true } }]); + expect(handler.updated).to.equal(1); + }); + + it('should update risk assessments settings with newDevice', async () => { + const auth0 = { + riskAssessments: { + updateSettings: (data) => { + expect(data).to.be.an('object'); + expect(data.enabled).to.equal(true); + return Promise.resolve({ data }); + }, + updateNewDeviceSettings: (data) => { + expect(data).to.be.an('object'); + expect(data.remember_for).to.equal(30); + return Promise.resolve({ data }); + }, + }, + }; + + const handler = new riskAssessment.default({ client: auth0 }); + const stageFn = Object.getPrototypeOf(handler).processChanges; + + await stageFn.apply(handler, [ + { riskAssessment: { enabled: true, newDevice: { remember_for: 30 } } }, + ]); + expect(handler.updated).to.equal(1); + }); + + it('should update risk assessments settings to disabled', async () => { + const auth0 = { + riskAssessments: { + updateSettings: (data) => { + expect(data).to.be.an('object'); + expect(data.enabled).to.equal(false); + return Promise.resolve({ data }); + }, + }, + }; + + const handler = new riskAssessment.default({ client: auth0 }); + const stageFn = Object.getPrototypeOf(handler).processChanges; + + await stageFn.apply(handler, [{ riskAssessment: { enabled: false } }]); + expect(handler.updated).to.equal(1); + }); + + it('should not process changes if riskAssessment is not provided', async () => { + const auth0 = { + riskAssessments: { + updateSettings: () => { + throw new Error('updateSettings should not be called'); + }, + }, + }; + + const handler = new riskAssessment.default({ client: auth0 }); + const stageFn = Object.getPrototypeOf(handler).processChanges; + + await stageFn.apply(handler, [{}]); + expect(handler.updated).to.equal(0); + }); + + it('should handle API errors properly', async () => { + const auth0 = { + riskAssessments: { + updateSettings: () => { + const error = new Error('API Error'); + error.statusCode = 500; + throw error; + }, + }, + }; + + const handler = new riskAssessment.default({ client: auth0 }); + const stageFn = Object.getPrototypeOf(handler).processChanges; + + try { + await stageFn.apply(handler, [{ riskAssessment: { enabled: true } }]); + expect.fail('Should have thrown an error'); + } catch (err) { + expect(err.message).to.equal('API Error'); + } + }); + }); +}); diff --git a/test/utils.js b/test/utils.js index 00ef87ea..cca14822 100644 --- a/test/utils.js +++ b/test/utils.js @@ -163,6 +163,10 @@ export function mockMgmtClient() { connectionProfiles: { getAll: (params) => mockPagedData(params, 'connectionProfiles', []), }, + riskAssessments: { + getSettings: () => Promise.resolve({ data: { enabled: false } }), + getNewDeviceSettings: () => Promise.resolve({ data: { remember_for: 30 } }), + }, }; }