Skip to main content

Bundles

Discover how to work with Vault bundles to add JavaScript files that define and export functions for use as custom data type validators, normalizers, transformers, and actions.

A Vault bundle is a JavaScript file that defines and exports one or more functions. It contains all the code required by the implementations of its exported functions. These functions are used to define custom data types. The bundles JavaScript code must conform to ECMAScript 5.1 specifications.

This page describes the features of bundles and how to define them.

note

Bundles do not support the CommonJS require() function. However, you can use a bundler tool, such as esbuild or swc, to embed external modules in a bundle. See Using a bundler tool.

Define bundles

Bundles define validator, normalizer, transformer, and action functions. Each type function required a signature, as follows:

  • Validator

    Signature: (value: unknown) => boolean;

    The validator accepts a value and returns true if the value is determined to be valid.

  • Normalizer

    Signature: (value: unknown) => unknown;

    The normalizer accepts a valid value of the type and returns a normalized value. Two values have the same normalized value if they are considered equivalent instances of the type.

  • Transformation

    Signature:

      (   
    context: TransformationContext,
    object: Record<string, unknown>,
    value: unknown
    ) => unknown;

    where TransformationContext is defined as:

      interface TransformationContext {
    user: string;
    role: string;
    reason: string;
    collection: string;
    props: Record<string, string>;
    prop: string;
    param: string
    }

    A transformation accepts three arguments:

    • TransformationContext: an object that describes the context in which the transformer is invoked. This object contains:
      • user: the name of the user making the request.
      • role: the name of the role of the user making the request.
      • reason: the reason specified in the request.
      • collection: the name of the collection specified in the request.
      • props: a type map. It maps the name of each property in the collection to the name of its property type.
      • prop: the name of the property of which the third argument, value, is an instance.
      • param: an optional string passed in the request's X-Trans-Param custom header.
    • object: a map in which the key is the name of a property in the collection and the value is the value of that property. Vault populates the map with the names and values of properties where the names are listed in the dependencies.properties array in the exported prototype of the function.
    • value: the value of the property that is to be transformed.
  • Action

    Signature: (context: ActionContext) => unknown;

    where ActionContext is defined as:

    interface ActionContext {
    user: string;
    role: string;
    reason: string;
    body: unknown;
    vault: Vault;
    }

    An action accepts these arguments:

    • ActionContext: an object that describes the context in which the action is invoked. This object contains:
      • user: the name of the user making the request.
      • role: the name of the role of the user making the request.
      • reason: the reason specified in the request.
      • body: The HTTP request body of the request.
      • vault: an instance to a class that exposes methods to be used with Vault, such as decrypt.

    See Custom JavaScript action for more details.

Export functions

To export a type function from a bundle, you wrap it in a prototype object and export the prototype by adding it as a field in the exports object.

What is a prototype?

A prototype is a JavaScript object that describes the type function and references the function that implements it.

These are the fields of a prototype:

  • type: a string that specifies the type of the function, either validator, normalizer, transformation, or action.
  • description: an optional string that describes the function.
  • handler: the function or a reference to the function (but not the name of the function, which would be a string). See Writing a handler for more information.
  • dependencies: an optional object that specifies the dependencies of the function. It contains these optional fields:
    • properties: an array of strings that specify the names of the properties that should be provided to the function.

Writing a handler

The handler function that implements a type function must have the signature defined by the type field in the prototype. It can use any client-side JavaScript code and these functions made available in the Vault environment:

  • console.log

    Signature: | (...a: any[]) => void;

    This variadic function passes all its arguments to the Vault logger. The logger formats each argument as a string and outputs all the arguments, separated by a space, as a debug level log, prefixed by the text: "JS Log: ".

  • fetch

    Signature:

    (url: string, options?: {
    method?: string,
    body?: string,
    headers?: Record<string, string>
    }): Promise<{
    ok: boolean,
    status: number,
    json: () => Promise<unknown>
    text: () => Promise<string>
    }>

    Fetches a resource from the network. The Vault fetch function implement a compatible subset of the Fetch API.

    API calls made with this function are subject to the same restrictions as other outbound network requests in Vault. They must be configured as allowed in the PVAULT_SERVICE_ALLOWED_PCI_HTTP_DESTINATIONS environment variable.

Exporting Prototypes

To export a prototype, add it to the exports object. There are two ways to do this:

  1. Add a field to the exports object, assuming that the exports object exists. You can use either bracket notation or dot notation for this.

    For example, in a bundle called MyBundle, the following two assignments are equivalent, where by_country_prototype is a reference to a prototype or an inline definition of the prototype:

    // These two are equivalent:

    exports["by_country"] = by_country_prototype

    exports.by_country = by_country_prototype
  2. You can also redefine the exports object and write the following:

    exports = {
    "by_country" : by_country_prototype
    }

The name of the exported prototype is the name of the exports field where it is defined. Therefore, the qualified name of the type function defined in the by_country_prototype prototype is MyBundle.by_country_prototype.

Examples

Here is an example of a prototype that describes a transformation:

{
type: "transformer",
description: "This transformation is applied to emails and " +
"returns the value only if the role making the request " +
"is located in the same country as the object being read.",
handler: function allow_by_country(context, object, value) {
// The following assumes that role names have the country as a suffix.
// eg. admin_us, webapp_uk
if (object.country === context.role.split("_")[1]) {
return value;
}
// If not, return only the domain part of the email.
return value.split("@")[1];
},
dependencies: {
// Specifying the "country" property here ensures that Vault passes
// into the handler an object populated with the country value of the
// object whose value is being transformed.
properties: ["country"],
}
}

In this example, the handler is provided inline. It is also possible to define the function separately and assign a reference to the handler field, like so:

{
type: "transformer",
description: "This transformation is applied to emails and " +
"returns the value only if the role making the request " +
"is located in the same country as the object being read.",
handler: allow_by_country,
dependencies: {
properties: ["country"],
}
}

function allow_by_country(context, object, value) {
if (object.country === context.role.split("_")[1]) {
return value;
}
return value.split("@")[1];
}

To export the prototype, you add it to the exports object. The following example uses the bracket notation.

exports["by_country"] = {
type: "transformer",
handler: allow_by_country,
dependencies: {
properties: ["country"],
}
}
note

The function name exported by a prototype is the name of the exports field where it is referenced, not the name of the handler referenced by the prototype. Therefore, assuming that these examples are defined in a bundle named MyBundle, the qualified name of the exported type function is MyBundle.by_country and not MyBundle.allow_by_country.

How to create a bundle

The contents of a bundle is valid if:

  1. It is a JavaScript file.
  2. The code in the file is self-contained and does not refer to any external modules using the require() function or the import keyword.
  3. The code compiles.
  4. The bundle contains at least one exported type function.

If you do not need external modules in your handlers it is quite simple to write the bundle code in a JavaScript file.

If you do need to use external modules in your handlers, you have two options:

  1. Download the code of the external module and embed it in the JavaScript file of the bundle.
  2. Use a JavaScript bundler, such as esbuild. This method saves you the trouble of downloading the external module. Instead, you use the require() function in the file you provide as input to the bundler. Running the bundler downloads the required modules and embeds them in the output file that you specify. The output file can then be used as the contents of the bundle. See Using a bundler tool for more detail about using JavaScript bundlers.

If you use a bundler tool, you can also write your functions in TypeScript. The bundler tool can be configured to transpile the TypeScript to JavaScript.

Using a bundler tool

This walkthrough demonstrates how to use esbuild to create a bundle that exports a transformation that uses an external module, jsrsasign.

The transformation in this walkthrough is called "idor_protected". It implements Insecure Direct Object Reference (IDOR) to allow access to an object only if the caller can identify itself as the owner of the object.

The transformation returns the value being transformed only if:

  • The caller making the request for the object from Vault provides a JWT token in the custom X-Trans-Param header, passed to the function in TransformationContext.param.
  • The JWT token can be verified with a hard-coded public key.
  • The sub field in the JWT token is equal to the subject property of the object.

Prerequisites

You need npm and node.js installed. This walkthrough was prepared using npm version 8.19.2 and node.js version v18.10.0.

Create the project

Create a directory for the project, for instance, sample.

In the directory, create the idor.source.js file with this content:

const KJUR = require('jsrsasign');

exports = {
"idor_protected": {
type: "transformer",
handler: idor_protected,
dependencies: {
properties: ["subject"],
}
}
}

function idor_protected(context, object, value) {
const public_key = "616161";
const isValid = KJUR.jws.JWS.verifyJWT(
context.param,
key, {alg: ['HS256'],
sub: [object.subject]});

return isValid ? value : null
}

In the directory, create the following package.json file:

{
"name": "bundler",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"scripts": {
"build": "esbuild idor.source.js --bundle --outfile=./dist/idor.js --platform=node"
},
"dependencies": {
"esbuild": "^0.17.10",
"jsrsasign": "^10.6.1",
}
}

Build the project

In the sample directory, run npm run build.

npm downloads esbuild and jsrsasign into the node_modules directory and creates the output file idor.js in the dist directory.

The output file, dist/idor.js is a Vault-compatible bundle that exports the transformation "idor_protected" and uses the external module jsrsasign in its implementation.

If you name this bundle "idor", the qualified name of the export is idor.idor_protected.

note

Vault requires that the exports variable is defined in the global scope. Therefore, when using a bundler, configure it to comply with this requirement. For example, when using esbuild, you should provide the flag --platform=node or --format=cjs. If you don't do this, esbuild defaults to use iife, which does not comply with this requirement).

Saving the bundle

The bundle should be copied to the /etc/pvault/conf.d/pvault.bundles directory.