auth0-extensions/auth0-source-control-extension-tools

View on GitHub
src/auth0/handlers/rules.js

Summary

Maintainability
A
1 hr
Test Coverage
import ValidationError from '../../ValidationError';
import {
  dumpJSON, calcChanges, stripFields, duplicateItems
} from '../../utils';
import DefaultHandler from './default';
import log from '../../logger';

export const excludeSchema = {
  type: 'array',
  items: { type: 'string' }
};

export const schema = {
  type: 'array',
  items: {
    type: 'object',
    default: [],
    properties: {
      script: {
        type: 'string',
        description: 'A script that contains the rule\'s code',
        default: ''
      },
      name: {
        type: 'string',
        description: 'The name of the rule. Can only contain alphanumeric characters, spaces and \'-\'. Can neither start nor end with \'-\' or spaces',
        pattern: '^[^-\\s][a-zA-Z0-9-\\s]+[^-\\s]$'
      },
      order: {
        type: [ 'number', 'null' ],
        description: 'The rule\'s order in relation to other rules. A rule with a lower order than another rule executes first.',
        default: null
      },
      enabled: {
        type: 'boolean',
        description: 'true if the rule is enabled, false otherwise',
        default: true
      },
      stage: {
        type: 'string',
        description: 'The rule\'s execution stage',
        default: 'login_success',
        enum: [ 'login_success', 'login_failure', 'pre_authorize' ]
      }
    },
    required: [ 'name' ]
  }
};

export default class RulesHandler extends DefaultHandler {
  constructor(options) {
    super({
      ...options,
      type: 'rules',
      stripUpdateFields: [ 'stage' ] // Fields not allowed in updates
    });
  }

  async getType() {
    if (this.existing) return this.existing;
    this.existing = await this.client.rules.getAll({ paginate: true });
    return this.existing;
  }

  objString(rule) {
    return super.objString({ name: rule.name, order: rule.order });
  }

  async calcChanges(assets, includeExcluded = false) {
    let { rules } = assets;

    const excludedRules = (assets.exclude && assets.exclude.rules) || [];

    let existing = await this.getType();

    // Filter excluded rules
    if (!includeExcluded) {
      rules = rules.filter((r) => !excludedRules.includes(r.name));
      existing = existing.filter((r) => !excludedRules.includes(r.name));
    }

    // Figure out what needs to be updated vs created
    const {
      del, update, create, conflicts
    } = calcChanges(rules, existing, [ 'id', 'name' ]);

    // Figure out the rules that need to be re-ordered
    const futureRules = [ ...create, ...update ];

    const futureMaxOrder = Math.max(...futureRules.map((r) => r.order));
    const existingMaxOrder = Math.max(...existing.map((r) => r.order));
    let nextOrderNo = Math.max(futureMaxOrder, existingMaxOrder);

    const reOrder = futureRules.reduce((accum, r) => {
      const conflict = existing.find((f) => r.order === f.order && r.name !== f.name);
      if (conflict) {
        nextOrderNo += 1;
        accum.push({
          ...conflict,
          order: nextOrderNo
        });
      }
      return accum;
    }, []);

    return {
      del,
      update,
      create,
      reOrder,
      conflicts
    };
  }

  async validate(assets) {
    const { rules } = assets;

    // Do nothing if not set
    if (!rules) return;

    const excludedRules = (assets.exclude && assets.exclude.rules) || [];

    // Figure out what needs to be updated vs created
    const { update, create, del } = await this.calcChanges(assets, true);
    // Include del rules which are actually not going to be deleted but are excluded
    // they can still muck up the ordering so we must take it into consideration.
    const futureRules = [ ...create, ...update, ...del.filter((r) => excludedRules.includes(r.name)) ];

    // Detect rules with the same order
    const rulesSameOrder = duplicateItems(futureRules, 'order');
    if (rulesSameOrder.length > 0) {
      const formatted = rulesSameOrder.map((dups) => dups.map((d) => `${d.name}`));
      throw new ValidationError(`There are multiple rules for the following stage-order combinations
      ${dumpJSON(formatted)}.
       Only one rule must be defined for the same order number in a stage.`);
    }

    // Detect Rules that are changing stage as it's not allowed.
    const existing = await this.getType();
    const stateChanged = futureRules.reduce((changed, rule) => ([
      ...changed,
      ...existing.filter((r) => rule.name.toLowerCase() === r.name.toLowerCase() && r.stage !== rule.stage)
    ]), []).map((r) => r.name);

    if (stateChanged.length > 0) {
      throw new ValidationError(`The following rules changed stage which is not allowed:
      ${dumpJSON(stateChanged)}.
      Rename the rules to recreate them and avoid this error.`);
    }

    await super.validate(assets);
  }

  async processChanges(assets) {
    const { rules } = assets;

    // Do nothing if not set
    if (!rules) return;

    // Figure out what needs to be updated vs created
    const changes = await this.calcChanges(assets);

    // Temporally re-order rules with conflicting ordering
    await this.client.pool.addEachTask({
      data: changes.reOrder,
      generator: (rule) => this.client.updateRule({ id: rule.id }, stripFields(rule, this.stripUpdateFields)).then(() => {
        const updated = {
          name: rule.name, stage: rule.stage, order: rule.order, id: rule.id
        };
        log.info(`Temporally re-order Rule ${dumpJSON(updated)}`);
      })
    }).promise();

    await super.processChanges(assets, {
      del: changes.del,
      create: changes.create,
      update: changes.update,
      conflicts: changes.conflicts
    });
  }
}