This package provides an Express middleware audit framework. It's a framework in the sense that it may be extended to provde additional audit log destinations. The package includes two destinations:
-
Console - audit entries are sent to
stdout
viaconsole.log
. -
AWS SNS - audit entries are published to a topic managed by AWS Simple Notification Service (SNS).
As an Express middleware package, it's intended to be incorporated through an Express route in a manner like the following sample.
1 const audit = require('@isab/audit).middleware(options);
...
2 router.post('/MyResource',
3 cors(corsOptions),
4 audit,
5 authenticator.verifyBearer,
6 authorizeScope,
7 requestHandler );
The point of including so many middleware components is simply to demonstrate the intended order. The numbered descriptions below relate to the line numbers above.
- An
audit
middleware instance is configured through a set of properties. Without properties provided, aconsole
audit is configured. - This is the usual method and resource name specified on an Express route.
- If your route supports CORS, it should come before the audit middleware.
- The audit middleware should come early in the chain to make it available to other
middleware and the request handler. This step will add an
audit
property to theRequest
object. - Authentication middleware (just a sample).
- Authorization middleware (just a sample).
- The request handler.
A quick configuration can be obtained with the following Express application
that simply returns OK
. In this example we configure a response header
MyApp
that will return the request ID.
const express = require('express');
const audit = require('@isab/audit').middleware({responseHeader: "MyApp"});
const app = express();
const port = 6014;
app.get('/test', audit, (req, res) => {
req.audit.end();
req.audit.status = 200;
res.status(200).end('OK\n');
});
app.listen(port, () => console.log('Listening on', port));
The output from a curl
invocation shows the MyApp
response header
holding the request ID.
$ curl http://localhost:6014/test -i
HTTP/1.1 200 OK
X-Powered-By: Express
MyApp: ee49ed90-4603-11ea-9621-77baedfd4eb4
Date: Sun, 02 Feb 2020 21:35:28 GMT
Connection: keep-alive
Content-Length: 3
OK
The output in the Express stdout
will be sandwiched between two
displays of the request ID .
$ node minimalApp
Configuring console handler.
Listening on 6014
------ Start request ID ee49ed90-4603-11ea-9621-77baedfd4eb4 ----------
Start time: 2020-02-02T21:35:28.483Z
End time: 2020-02-02T21:35:28.491Z
Duration: 8 ms
HTTP Status: 200
Component: /test
Action: GET
Auth Token: missing
Client IP: ::1
Client ID: undefined
Issuer ID: undefined
Fields: undefined
Claims: {}
Errors: []
------ End request ID ee49ed90-4603-11ea-9621-77baedfd4eb4 ------------
Most of the fields are empty or undefined. In practice, these are populated within the request handlers as described below. Only a few fields are populated automatically.
The following properties apply to all destinations.
Property | Value | Default |
---|---|---|
emitter |
console or awssns |
console |
responseHeader |
Response header name for request ID | none |
quietInit |
true = suppress init logging |
false |
quietEmit |
true = suppress emit logging |
false |
If responseHeader
is not included in the options object, no response header
is added to the response by the audit middleware.
The configuration is echoed to stdout
when the module is initialized.
This may be suppressed with quietInit: true
. Most emitters will log
a summary of each emit. Set quietEmit: true
to suppress this.
The console
audit ignores this last flag.
These additional properties apply to AWS Simple Notification Services.
They are ignored when emitter
is console
.
Property | Value |
---|---|
awsRegion |
Region for AWS SNS |
awsTopicArn |
Topic ARN for AWS SNS |
awsKeyId |
API key for AWS user role |
awsSecret |
API secret for AWS user role |
It is suggested that desired values be provided through environment variables. The following sample demonstrates the configuration of an SNS audit middleware from the environment.
const emitter = process.env['AUDIT_EMITTER'] || 'console';
const responseHeader = process.env['AUDIT_RESPONSE_HEADER'] || 'x-request-idp';
console.log('Audit config:', emitter);
const auditOptions = { emitter, responseHeader };
if (emitter === 'awssns') {
auditOptions.awsRegion = process.env['AUDIT_AWS_REGION'];
auditOptions.awsTopicArn = process.env['AUDIT_AWS_TOPIC'];
auditOptions.awsKeyId = process.env['AUDIT_AWS_KEY'];
auditOptions.awsSecret = process.env['AUDIT_AWS_SECRET'];
}
const auditMiddleware = Audit.middleware(auditOptions);
The middleware will echo the configuration to stdout
. It will not divulge
an awsSecret
property, only whether it is set.
Configuring audit middleware is easy. Using it soundly in your request handler requires more work. Two principles to keep in mind while writing your request handler are
-
An
audit
property on theRequest
object contains the audit instance. -
The request handler must call
audit.end()
before exiting the handler, otherwise the record is never emitted.
This latter can be difficult to accommodate in the presence of multiple (often asynchronous) code paths for a request handler. The following is an example of a callback passed to a database library. It has the typical signature of an error and a data object.
1 const audit = req.audit;
...
2 if (err) {
3 response = {
4 code: err.code,
5 errno: err.errno,
6 sqlMessage: err.sqlMessage ? err.sqlMessage.slice(0, 60) : 'None'
7 }
8 dbErrorLogger(err);
9 res.status(500);
10 audit.status = 500;
11 audit.errors.push(err.code);
12 audit.errors.push(err.sqlMessage);
13 } else {
14 response.insertId = results.insertId;
15 res.status(200);
16 audit.status = 200;
17 }
18 res.json(response);
19 audit.end();
Lines 9 and 15 show the setting of the HTTP status code on the audit object.
Lines 11 and 12 demonstrate that error messages are pushed onto the error
array.
Line 19 invokes end()
.
There will typically be a call to end()
at the end of each asynchronous
code path. This is not simply something that can be cleaned up at the end
of the current event loop.
When unit testing your request handlers, you'll need to supply an audit instance. Two approaches are
-
Use a tool like Jest to simulate an Audit instance through spies (mock functions) to ensure the audit function is invoked as expected.
-
Provide a ConsoleAudit instance to the test invocation. This won't avail itself to
Expect
methods like.toHaveBeenCalled
. But you can still inspect the state of theAudit
instance withExpect
methods.
To get the second option working in your Jest tests, incorporate the following pattern into your tests.
const AuditConfig = require('@isab/audit');
let audit;
beforeEach(() => {
audit = AuditConfig.instance();
});
test('Should find one error with this request', () => {
// Create mocks for req and res.
handler(req, res, audit); // this tests expects one client error.
expect(audit.errors).toHaveLength(1);
expect(audit.status).toBe(400);
expect(audit.done).toBe(true);
});
One can easily extend the Audit
class to customize your own output (in the
case of the Console
) or a different audit destination. Each extension module
exports
middleware
- the(req, res, next)
function that provides the middleware- the class itself
Only the first is strictly necessary. Normal usage of the the audit doesn't
involve direct access to the Audit
subclass. But it can be useful for
unit testing.
For extension samples, check the following classes in the GitHub repository:
For a sample of processing SNS events from SnsAudit
, see the Lambda function in
the aws
folder.