Small opinionated Hapi.js plugin that generates audit logs for RESTful APIs.
- Requirements
- Installation
- Testing
- About
- Quickstart
- Audit Log Document Schemas
- Example Audit Log Documents
- API
- Flows & Audit Log Data
- Error handling
- License
Works with Hapi.js v18 or higher, Node.js v14 or higher. For compatibility with Node.js v12 check version 3.
npm i -S hapi-audit-rest
npm test
This plugin creates audit log documents based on REST semantics.
HTTP method | Description | Audit Log Document |
---|---|---|
GET | Retrieve resources | Action |
POST | Create a new resource | Mutation - Create |
PUT | Update a resource | Mutation - Update |
DELETE | Delete a resource | Mutation - Delete |
Mutations track old and new state of a resource to effectively reason about state changes.
For every request an event is emitted with an audit log document.
await server.register({
plugin: require("hapi-audit-rest"),
});
{
application: String
type: String,
body: {
entity: String,
entityId: String|Number|Null,
action: String,
username: String|Null,
data: Object|Null,
timestamp: String,
},
outcome: String
};
{
application: String
type: String,
body: {
entity: String,
entityId: String|Number|Null,
action: String,
username: String|Null,
originalValues: Object|Array|Null,
newValues: Object|Array|Null,
timestamp: String,
},
outcome: String
};
Consider a CRUD API on users.
// emitted data on GET /api/users?page=1&limit=10&sort=asc&column=id
{
application: "my-app",
type: "SEARCH",
body: {
entity: "/api/users",
entityId: null,
action: "SEARCH",
username: null, // or the username if authenticated
timestamp: "2021-02-13T18:11:25.917Z",
data: {
page: 1,
limit: 10,
sort: 'asc',
column: 'id'
},
},
outcome: "Success",
};
// emitted data on GET /api/users/1
{
application: "my-app",
type: "SEARCH",
body: {
entity: "/api/users/1",
entityId: "1",
action: "SEARCH",
username: null, // or the username if authenticated
timestamp: "2021-02-13T18:11:25.917Z",
data: {},
},
outcome: "Success",
};
// consider the payload
const user = {
username: "user",
firstName: "first name",
lastName: "last name",
};
// emitted data on POST /api/users, with payload user, created user with id returned in response
{
application: "my-app",
type: "MUTATION",
body: {
entity: "/api/users",
entityId: 1,
action: "CREATE",
username: null, // or the username if authenticated
originalValues: null,
newValues: {
id: 1,
username: "user",
firstName: "first name",
lastName: "last name",
},
timestamp: "2021-02-20T20:53:04.821Z",
},
outcome: "Success",
};
// emitted data on DELETE /api/users/1
{
application: "my-app",
type: "MUTATION",
body: {
entity: "/api/users/1",
entityId: 1,
action: "DELETE",
username: null, // or the username if authenticated
originalValues: {
id: 1,
username: "user",
firstName: "first name",
lastName: "last name",
},
newValues: null,
timestamp: "2021-02-20T20:53:04.821Z",
},
outcome: "Success",
};
// consider the payload
const user = {
firstName: "updated first",
};
// emitted data on PUT /api/users/1
{
application: "my-app",
type: "MUTATION",
body: {
entity: "/api/users/1",
entityId: 1,
action: "UPDATE",
username: null, // or the username if authenticated
originalValues: {
id: 1,
username: "user",
firstName: "first name",
lastName: "last name",
},
newValues: {
firstName: "updated first", // use option fetchNewValues for the whole updated entity object
},
timestamp: "2021-02-20T20:53:04.821Z",
},
outcome: "Success",
};
await server.register({
plugin: require("hapi-audit-rest"),
options: {
// plugin registration options
},
});
Common use cases for isAuditable option:
await server.register({
plugin: require("hapi-audit-rest"),
options: {
isAuditable: ({ auth: { isAuthenticated }, method, url: { pathname } }) => {
// do not audit unauthenticated requests
if (!isAuthenticated) {
return false
}
// do not audit GET requests
if (method === "get") {
return false
}
// do not audit requests when path does not start from /api
if (!pathname.startsWith("/api")) {
return false
}
// return true to audit all other cases
return true
}
},
});
Common use cases for setEntity option:
await server.register({
plugin: require("hapi-audit-rest"),
options: {
// use the standard pattern of an api i.e. /api/v1.0/users, to refine the entity name
// will have 'entity: users' in audit log
setEntity: (path) => path.split("/")[3],
}
},
});
// at any route
options: {
plugins: {
"hapi-audit-rest": {
// plugin route options
}
}
}
By default the plugin applies to all registered routes. Should you need to exclude any, apply to the route:
options: {
plugins: {
"hapi-audit-rest": false,
},
}
To effectively track old and new state of a resource, the plugin implements internal flows based on the following semantics:
HTTP method | Scope | Description |
---|---|---|
GET | collection | Retrieve all resources in a collection |
GET | resource | Retrieve a single resource |
POST | resource | Create a new resource in a collection |
PUT | resource | Update a resource |
DELETE | resource | Delete a resource |
To override audit log document defaults use the route extension point. To completely override any created audit log document use the global override registration option extend all.
An action audit log document is created, on pre-response lifecycle if the request succeeds with the following defaults:
{
application: "my-app", // or the clientId if specified
type: "SEARCH",
body: {
entity: $, // as specified by setEntity function
entityId: null,
action: "SEARCH",
username: null, // or the username if authenticated
timestamp: Date.now(),
data: request.query,
},
outcome: "Success",
};
An action audit log document is created, on pre-response lifecycle if the request succeeds with the following defaults:
{
application: "my-app", // or the clientId if specified
type: "SEARCH",
body: {
entity: $, // as specified by setEntity function
entityId: request.params.id,
action: "SEARCH",
username: null, // or the username if authenticated
timestamp: Date.now(),
data: request.query,
},
outcome: "Success",
};
The response is cached if cashing enabled.
A mutation audit log document is created on pre-response lifecycle if the request succeeds with the following defaults:
{
application: "my-app", // or the clientId if specified
type: "MUTATION",
body: {
entity: $, // as specified by setEntity function
entityId: request.response.source.id || request.payload.id,
action: "CREATE",
username: null, // or the username if authenticated
originalValues: null,
newValues: request.response.source || request.payload, // the response or the payload if response null
timestamp: Date.now()",
},
outcome: "Success",
};
- POST mutations rely to request payload or response payload to track the new resource state. If request is streamed to an upstream server this will result to an error.
In cases that it is not meaningful to audit a mutation, an action audit log document can be created by setting isAction route parameter.
{
application: "my-app", // or the clientId if specified
type: "SEARCH",
body: {
entity: $, // as specified by setEntity function
entityId: request.params.id || request.payload.id,
action: "SEARCH",
username: null, // or the username if authenticated
timestamp: Date.now(),
data: request.payload, // or null if request streamed
},
outcome: "Success",
};
A mutation audit log document is created on pre-response lifecycle if the request succeeds with the following defaults:
{
application: "my-app", // or the clientId if specified
type: "MUTATION",
body: {
entity: $, // as specified by setEntity function
entityId: request.params.id || newValues.id, // where newValues is either the request payload (default) or the resource data fetched after update when fetchNewValues=true or request streamed
action: "UPDATE",
username: null, // or the username if authenticated
originalValues: $, // values fetched with injected GET by id call (or loaded from cache)
newValues: request.payload || newValues, // newValues = values fetched by injected GET by id call when fetchNewValues=true or request streamed
timestamp: Date.now()",
},
outcome: "Success",
};
PUT mutations are the most complex.
- Before the update, the original resource state is retrieved by inspecting the cache. If not in cache a GET by id request is injected based on the current request path (custom path can be set on route with setInjectedPath).
- After the update, the new resource state is retrieved from the request payload. If the request is streamed or the fetchNewValues option is set, a GET by id request will be injected to fetch the new resource state.
In cases that it is not meaningful to audit a mutation, an action audit log document can be created by setting isAction route parameter.
{
application: "my-app", // or the clientId if specified
type: "SEARCH",
body: {
entity: $, // as specified by setEntity function
entityId: request.params.id || request.payload.id,
action: "SEARCH",
username: null, // or the username if authenticated
timestamp: Date.now(),
data: request.payload, // or null if request streamed
},
outcome: "Success",
};
A mutation audit log document is created on pre-response lifecycle if the request succeeds with the following defaults:
{
application: "my-app", // or the clientId if specified
type: "MUTATION",
body: {
entity: $, // as specified by setEntity function
entityId: request.params.id || originalValues.id, // where originalValues = resource state before delete
action: "DELETE",
username: null, // or the username if authenticated
originalValues: $, // values fetched with injected GET by id request before delete
newValues: null,
timestamp: Date.now()",
},
outcome: "Success",
};
DELETE mutations retrieve old resource state by injecting a GET by id request before the delete operation.
In cases that it is not meaningful to audit a mutation, an action audit log document can be created by setting isAction route parameter.
{
application: "my-app", // or the clientId if specified
type: "SEARCH",
body: {
entity: $, // as specified by setEntity function
entityId: request.params.id || request.payload.id,
action: "SEARCH",
username: null, // or the username if authenticated
timestamp: Date.now(),
data: request.payload, // or null if request streamed
},
outcome: "Success",
};
When an error occurs, it is logged using the request.log(tags, [data])
method:
- tags: "error", "hapi-audit-rest"
- data: error.message
The server isntance can interact with log information:
server.events.on({ name: "request", channels: "app" }, (request, event, tags) => {
if (tags.error && tags["hapi-audit-rest"]) {
console.log(event); // do something with error data
}
});
If debug
option is enabled (disabled by default), the error message will be printed to stderr for convenience.
hapi-audit-rest is licensed under a MIT License.