Add audit logging and log streams to a Node Express app
Building a SaaS product is no small feat. And when you start selling to large customers, the list of requirements gets even longer — but if you want to land those six-figure (and beyond) contracts, you'll need to land enterprise-level features.
In this tutorial, we’re going to learn how to add audit logging and log streams to an existing Node app built with Express using WorkOS.
Get the project cloned and running
This demo adds audit logging to an existing Node app built using Express. We’ll focus exclusively on how to configure and send audit logging events via Node middleware using the WorkOS audit logging functionality.
To get started, clone the app:
# clone the repo
gh repo clone learnwithjason/node-express-audit-log-event-stream-workos
# move into the directory
cd node-express-audit-log-event-stream-workos/
# install dependencies
npm i
Next, get the required environment variables:
WORKOS_API_KEY=""
WORKOS_CLIENT_ID=""
WORKOS_REDIRECT_URI="http://localhost:3000/auth/callback"
WORKOS_ORG_ID=""
WORKOS_DIRECTORY_ID=""
WORKOS_WEBHOOK_SECRET=""
SESSION_SECRET=""
These environment variables are all part of getting the SSO and SCIM integrations running. Once these are in place, there are no additional credentials required to add audit logging.
With the environment variables saved, start the app locally:
npm run dev
This will open the app at http://localhost:3000
. Open it in your browser and log in with your SSO credentials to see the dashboard.
What is audit logging?
On bigger teams, knowing who did what inside the app is a big deal. It creates accountability and makes sure the company is confident that every action can be attributed to one of their team members.
That’s what audit logging is designed to solve: provide a uniform API for keeping track of who did what inside the app to ensure there’s a paper trail.
Practically speaking, an audit log is a JSON object with data about who did what to which parts of the app that gets sent as an event. These events are stored somewhere (in our case, in WorkOS), and can be further sent (via log streams) to destinations like Amazon S3, Datadog, or Splunk.
In WorkOS, an audit logging event looks something like this:
{
"actor": {
"id": "00ua...",
"type": "user"
},
"action": "user.login",
"context": {
"location": "0.0.0.0",
"user_agent": "Mozilla/5.0 ..."
},
"targets": [
{
"id": "00ua...",
"type": "user"
},
{
"id": "directory_group_01...",
"type": "team"
}
],
"occurred_at": "2023-11-07T00:21:32.898Z"
}
There are 5 parts to it:
actor
— who is taking the action? Providing a type (e.g.user
orsystem
) along with an identifier provides auditors with the ability to identify who did the thing.action
— what was done? This is a unique identifier chosen by whoever sets up audit logging for the team.context
— location and browser information about the actortargets
— an array of what was acted upon. For example, an event might include the post that was affected, along with the user’s team. These targets can be used to group events during audits (e.g. “show me everything that’s happened to this post”).occurred_at
— a timestamp for when the action happened.
The good news is that after a bit of initial setup, audit logging is as straightforward as console logging in an Express app. Let’s build it!
Create a new file for audit logging middleware
Usually, audit logging is being added to an already existing app. Because of that, it’s likely that we already have most of the information we need configured: the user’s information, the actions they can take, and so on.
In Express, this information can be added to session storage, and since much of what we’ll need is going to be the same for every event (e.g. the user’s ID), we can use Express middleware to provide a helper function for logging that will have most of the work already done for the dev — it makes the right thing the easy thing, which (hopefully) means it will actually get used without someone needing to hound the dev team to implement it.
Set up this middleware by creating a new file at src/middleware/audit-logging.js
and add the following code inside:
const { WorkOS } = require('@workos-inc/node');
const workos = new WorkOS(process.env.WORKOS_API_KEY);
exports.auditLoggingMiddleware = (req, res, next) => {
// get the IP and user agent of the request, if available
const ip = req.headers['x-forwarded-for'] ?? '';
const userAgent = req.headers['user-agent'] ?? '';
req.log = async ({ action, targets = [] }) => {
if (!req.session.user || !req.session.user.idpId) {
console.error('unable to send audit log events without a valid user');
return;
}
const userId = req.session.user.idpId;
const groups = req.session.user.groups ?? ['missing'];
// all events get attached to the user and team(s) they’re part of
const defaultTargets = [
{ type: 'user', id: userId },
...groups.map((groupId) => ({ type: 'team', id: groupId })),
];
await workos.auditLogs.createEvent(process.env.WORKOS_ORG_ID, {
action,
actor: {
type: 'user',
id: userId,
},
occurredAt: new Date(),
targets: defaultTargets.concat(targets),
context: {
location: ip,
userAgent,
},
});
};
next();
};
After getting a new instance of the WorkOS Node SDK, this file exports a new middleware function called auditLoggingMiddleware
.
Inside, it tries to grab the IP address and user agent from the current request, then attaches a new function called log
to the request before continuing the request with next()
. This middleware will run before all routes, which means every route will now have access to req.log()
for sending audit logging events.
The req.log
function accepts an options argument with the name of the action and an optional array of targets. Everything else required will already be present in the session thanks to the auth middleware. (We’ll look at specifically what’s needed in the next section.)
Inside req.log
, the function will:
- Check for a logged in user and bail if none is found
- Grab the user’s ID and group(s) from the session
- Create default targets for the user and each group the user belongs to
- Call the WorkOS SDK’s
createEvent
method to send an audit logging event
createEvent
requires two arguments:
- The organization ID to attach the event to
- The event to log
The event itself is mostly populated from existing data. The only things that the developer needs to provide are the action type and an optional array of additional targets to attach the event to.
This setup makes it far less cumbersome to send an audit logging event. In the minimum use case, all the developer needs to do is something like this in a route:
req.log({ action: 'some.action' });
This will create an event that’s properly associated with the user and their group(s), along with additional information to help auditors.
To add additional targets, the developer only needs the ID to send along:
req.log({
action: 'some.action',
targets: [
{
type: 'document',
id: documentId,
},
],
});
Now that we’ve got this set up, let’s add it to the app.
Use the custom Express middleware for audit logging
In src/index.js
, add the following code:
const { join } = require('node:path');
const express = require('express');
const session = require('express-session');
const pgSimple = require('connect-pg-simple');
const app = express();
const sessionStore = pgSimple(session);
+ const { auditLoggingMiddleware } = require('./middleware/audit-logging');
app.set('view engine', 'ejs');
app.set('views', join(__dirname, 'views'));
app.use(
session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
store: new sessionStore({
createTableIfMissing: true,
conObject: {
database: 'postgres',
},
}),
}),
);
app.use(express.static(join(__dirname, 'static')));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
+ app.use(auditLoggingMiddleware);
app.use('/', require('./routes/public'));
app.use('/auth', require('./routes/auth'));
app.use('/dashboard', require('./routes/dashboard'));
app.use('/api', require('./routes/api'));
const port = 3000;
app.listen(port, () => {
console.log(`app listening at http://localhost:${port}`);
});
Once this is added, every route in the app will have access to the req.log
method!
Make sure the required data is available in the session
Because we want to attach audit logging events to both the user and the user’s teams, we need to make sure the appropriate data is added to the user session.
Inside src/routes/auth.js
, find the req.get('/callback', ...)
route and make the following changes:
router.get('/callback', async (req, res) => {
const { code } = req.query;
const { profile } = await workos.sso.getProfileAndToken({
code,
clientID: process.env.WORKOS_CLIENT_ID,
});
// store the user in the app database
const user = await db.getUserByEmail(profile.email);
+ const dirUser = await workos.directorySync
+ .listUsers({
+ directory: process.env.WORKOS_DIRECTORY_ID,
+ })
+ .then(({ data }) => data.find((u) => u.idpId === profile.idpId));
req.session.user = profile;
req.session.user.id = user.id;
+ req.session.user.idpId = profile.idpId;
req.session.user.roles = user.roles;
+ req.session.user.groups = dirUser.groups.map((g) => g.id);
res.redirect('/dashboard');
});
Users in this app log in with SSO and are synced with a user directory using SCIM, so we use WorkOS’s directory sync to look up all the users and match the current user to get access to all their current groups.
Next, we attach the identity provider ID and an array of group IDs to the user’s session object.
Define audit logging events in WorkOS
Next, head over to your WorkOS dashboard, navigate to Configuration > Audit Logs, and click ”+ Create an event” to add all the events required for this app. This demo uses 5 event types:
user.login
, with target types ofuser
andteam
user.logout
, with target types ofuser
andteam
user.invite
, with target types ofuser
andteam
post.create
, with target types ofpost
,user
andteam
post.delete
, with target types ofpost
,user
andteam
Once these are created, we can add the audit log calls inside our app.
Add audit logging to an Express app
In any route that should be logged, add a new call to the req.log
method.
In src/routes/api.js
, update the /delete-post/:post_id
route:
router.get('/invite/:id', async (req, res) => {
// TODO implement invite system
console.log(`Invited user: ${req.params.id}`);
+ req.log({
+ action: 'user.invite',
+ targets: [{ type: 'user', id: String(req.params.id) }],
+ });
+
res.redirect('/dashboard/team');
});
router.get('/delete-post/:post_id', async (req, res) => {
+ req.log({
+ action: 'post.delete',
+ targets: [{ type: 'post', id: String(req.params.post_id) }],
+ });
+
await db.query(
db.sql`
DELETE FROM posts
WHERE id = $1
`,
[req.params.post_id],
);
res.redirect('/dashboard');
});
In src/routes/auth.js
, update the /callback
and /logout
routes:
router.get('/callback', async (req, res) => {
const { code } = req.query;
const { profile } = await workos.sso.getProfileAndToken({
code,
clientID: process.env.WORKOS_CLIENT_ID,
});
// store the user in the app database
const user = await db.getUserByEmail(profile.email);
const dirUser = await workos.directorySync
.listUsers({
directory: process.env.WORKOS_DIRECTORY_ID,
})
.then(({ data }) => data.find((u) => u.idpId === profile.idpId));
req.session.user = profile;
req.session.user.id = user.id;
req.session.user.idpId = profile.idpId;
req.session.user.roles = user.roles;
req.session.user.groups = dirUser.groups.map((g) => g.id);
+ await req.log({ action: 'user.login' });
+
res.redirect('/dashboard');
});
- router.get('/logout', (req, res) => {
+ router.get('/logout', async (req, res) => {
+ await req.log({ action: 'user.logout' });
+
req.session.user = null;
req.session.save();
res.redirect('/');
});
And finally, in src/routes/dashboard.js
, update the /new
route for POST
requests:
router.post('/new', async (req, res) => {
const { title, content } = req.body;
const user_id = req.session.user.id;
try {
const result = await db.query(
db.sql`
INSERT INTO posts (user_id, title, content)
VALUES ($1, $2, $3)
+ RETURNING id;
`,
[user_id, title, content],
);
console.log(result);
+ await req.log({
+ action: 'post.create',
+ targets: [{ type: 'post', id: String(result.rows.at(0).id) }],
+ });
} catch (err) {
console.error(err);
}
res.redirect('/dashboard');
});
Perform actions and check the log in WorkOS
After saving, try logging in, logging out, creating and deleting posts, or inviting a team member. After the actions have been performed, head to your WorkOS dashboard and view the log for your organization. You’ll see audit logging events showing up in real time!
Add log streams to integrate with your Security Incident and Event Management (SIEM) provider
No team wants to open a bunch of different dashboards to monitor the dozens of tools used by the company. Setting up log streams allows them to bring all the logs together into a central SIEM provider, and it all happens without any code changes required.
Inside WorkOS, go to your organization and click “Configure manually” under Log Streams, then choose your SIEM provider (currently supported: Amazon S3, Datadog, and Splunk), then add the configuration details on the next screen to get logs streaming to your provider.
And that’s it! Log streams are handled and your teams don’t need to add another dashboard to their list.
Audit logging and log streaming are important to larger customers
Remember: as companies grow, features like audit logging and log streams start to become critical factors in their decision to adopt a new product.
These kinds of features maybe don’t feel exciting to implement, but you know what is exciting? Closing a six-figure deal with a huge new customer.
Thanks again to WorkOS for sponsoring this tutorial. Happy building, friends!