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.
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'sX-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 thedependencies.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 asdecrypt
.payments
: an instance to a class that exposes methods to handle payment related operations.
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, eithervalidator
,normalizer
,transformation
, oraction
.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:
-
Add a field to the
exports
object, assuming that theexports
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, whereby_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 -
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"],
}
}
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:
- It is a JavaScript file.
- The code in the file is self-contained and does not refer to any external modules using the
require()
function or theimport
keyword. - The code compiles.
- 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:
- Download the code of the external module and embed it in the JavaScript file of the bundle.
- Use a JavaScript bundler, such as
esbuild
. This method saves you the trouble of downloading the external module. Instead, you use therequire()
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 inTransformationContext.param
. - The JWT token can be verified with a hard-coded public key.
- The
sub
field in the JWT token is equal to thesubject
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
.
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.