Skip to content

feat: [IDS-7232] Rule template update#439

Open
sauntimo wants to merge 3 commits into
masterfrom
rule-template-update
Open

feat: [IDS-7232] Rule template update#439
sauntimo wants to merge 3 commits into
masterfrom
rule-template-update

Conversation

@sauntimo
Copy link
Copy Markdown
Contributor

@sauntimo sauntimo commented May 13, 2026

✏️ Changes

  • adds two kinds of tests for the rule which the extension builds;
    • template tests which assert presence or absence of strings based on toggles in the rule config
    • runtime tests which assert modifications to the user object, and external calls, based on different inputs
  • updates the rule which the extension
    • removes lodash as a dependency which should improve performance
    • avoids possibility of calling callbacks twice
    • see context for these changes in slack thread
Built rule
/*
*  This rule been automatically generated by auth0-authz-extension
*  Updated by tim.saunders@auth0.com, 2026-05-13T12:30:19.430Z
 */
async function (user, context, callback) {
  var EXTENSION_URL = "https://sauntimo-us-a.us.webtask.run/adf6e2f2b84784b57522e3b19dfc9201";

  var audience = '';
  audience = audience || (context.request && context.request.query && context.request.query.audience);
  if (audience === 'urn:auth0-authz-api') {
    return callback(new UnauthorizedError('no_end_users'));
  }

  audience = audience || (context.request && context.request.body && context.request.body.audience);
  if (audience === 'urn:auth0-authz-api') {
    return callback(new UnauthorizedError('no_end_users'));
  }

  // Convert groups to array
  function parseGroups(data) {
    if (typeof data === 'string') {
      // split groups represented as string by spaces and/or comma
      return data.replace(/,/g, ' ').replace(/\s+/g, ' ').split(' ');
    }
    return data;
  }

  // Get the policy for the user.
  function getPolicy(user, context) {
    return new Promise((resolve, reject) => {
      request.post({
        url: EXTENSION_URL + "/api/users/" + user.user_id + "/policy/" + context.clientID,
        headers: {
          "x-api-key": configuration.AUTHZ_EXT_API_KEY
        },
        json: {
          connectionName: context.connection || user.identities[0].connection,
          groups: parseGroups(user.groups)
        },
        timeout: 5000
      }, (err, res, data) => {
        if (err) return reject(err);
        resolve({ res, data });
      });
    });
  }

  // Store authorization data in the user profile so we can query it later.
  async function saveToMetadata(user, groups, roles, permissions) {
    user.app_metadata = user.app_metadata || {};
    user.app_metadata.authorization = {
      groups: mergeRecords(user.groups, groups),
      roles: mergeRecords(user.roles, roles),
      permissions: mergeRecords(user.permissions, permissions)
    };

    await auth0.users.updateAppMetadata(user.user_id, user.app_metadata);
  }

  // Merge the IdP records with the records of the extension.
  function mergeRecords(idpRecords, extensionRecords) {
    idpRecords = idpRecords || [];
    extensionRecords = extensionRecords || [];

    if (!Array.isArray(idpRecords)) {
      idpRecords = idpRecords.replace(/,/g, ' ').replace(/\s+/g, ' ').split(' ');
    }

    return [...new Set([...idpRecords, ...extensionRecords])];
  }

  try {
    const { res, data } = await getPolicy(user, context);

    if (res.statusCode !== 200) {
      console.log('Error from Authorization Extension:', res.body || res.statusCode);
      return callback(
        new UnauthorizedError('Authorization Extension: ' + ((res.body && (res.body.message || res.body)) || res.statusCode))
      );
    }

    // Update the user object.
    user.groups = mergeRecords(user.groups, data.groups);
    user.roles = mergeRecords(user.roles, data.roles);
    user.permissions = mergeRecords(user.permissions, data.permissions);


    await saveToMetadata(user, data.groups, data.roles, data.permissions);


return callback(null, user, context);
  } catch (err) {
    console.log('Error from Authorization Extension:', err);
    return callback(new UnauthorizedError('Authorization Extension: ' + err.message));
  }
}

🔗 References

🎯 Testing

Functional tests

  • this version is built and deployed to /extensions/develop
  • using chrome overrides and the pre-built version of extensions gallery (ask Tim for this if you don't have a copy), install this dev version of the extension in a prod tenant
  • open the extension ui and go to configuration. Enable all the rule options and publish the rule
  • open the extensions page in demozero in the tenant you're using and reveal the built rule (which will have been fetched on page load) and check it matches the new version from this PR
  • in the demozero extensions page, use the button to add authz data if you don't already have some in your tenant (optionally use the button to create 10 users, or it will use the first 10 users it finds)
  • in the demozero extensions page use the button to add the authz action if you haven't already, this adds the authz data in the user object and app metadata to the token to make it possible to inspect what the rule did
  • use the extensions page to log in as users with authz data (and no authz data) and check that the token shows the correct roles, groups and permissions. You may want to consult the manual testing doc.

Local Unit Tests

  • run all unit tests locally npm run test

  • run rules unit tests locally npx mocha tests/unit/mocha.js 'tests/unit/server/lib/rules/authorize.tests.js'

  • now to prove that the tests demonstrate that the rule has not changed functionally, run the tests against the old version of the rule

    • replace the contents of authorize.js with the original version from master here
    • in authorize.test.js, comment back in // if (mod === 'lodash') return _;
    • run rules unit tests locally again

✅ This change has been tested in a Webtask
✅ This change has unit test coverage
🚫 This change has integration test coverage
🚫 This change has been tested for performance

🚀 Deployment

✅ This can be deployed any time

🎡 Rollout

In order to verify that the deployment was successful we will test in a prod tenant

🔥 Rollback

If there are issues with this change, we will revert the PR and deploy a new version of the extension

@sauntimo sauntimo self-assigned this May 13, 2026
@sauntimo sauntimo marked this pull request as ready for review May 13, 2026 14:35
user.permissions = mergeRecords(user.permissions, data.permissions);<% } %>

<% if (config.persistGroups || config.persistRoles || config.persistPermissions) { %>
await saveToMetadata(user, data.groups, data.roles, data.permissions);
Copy link
Copy Markdown
Contributor

@nkavtur nkavtur May 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the previous version, if saveToAppMetadata fails, it was returning return callback(err, user, context);. Now it returns just callback(new UnauthorizedError('Authorization Extension: ' + err.message));. Is that done intentionally?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nkavtur yes - the second two arguments are ignored when the first is an error

async function (user, context, callback) {
  try {
    throw new Error("Fake test error in rule");
    return callback(null, user, context);
  } catch (err) {
    return callback(err, user, context);
  }
}
Image

in fact any non-null first argument results in a 403

async function (user, context, callback) {
  return callback("a non-null value");
}
Image

The new version does result in Unauthorized (401) rather than Forbidden (403) but I think this is better because it's consistent with other errors returned by the rule

async function (user, context, callback) {
  try {
    throw new Error("Fake test error in rule");
    return callback(null, user, context);
  } catch (err) {
    return callback(new UnauthorizedError('Authorization Extension: ' + err.message));
  }
}
Image

Looking in tenants logs, it also seems to include the connection, which the other errors did not, which is also an advantage. Looking in the log details, the bodies looked similar

Image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants