Introduction
Identity Gateway (IG) has a record custom audit events extension point that can be used to capture custom events not covered by the default auditing framework.
The default IG audit framework covers capturing the request as it hits IG and the response as it leaves IG, this does not cover any additional request/responses being sent to a service component as a part of the overall transaction, for example calling out to ForgeRock Identity Cloud or Access Management (AM), to validate a user’s session or to request a policy evaluation.
Note: The code and configuration provided here are for sample and demonstration purposes only. It is not formally tested or supported by ForgeRock.
The Required Components
The extension example provided in the IG documentation covers what components are required as a basis for this extension to work:
- a schema of an event topic - describes the structure of the audit event to capture
- a Groovy script to generate audit events - the link between the data and the event
- an AuditService - defines how audit event handlers are configured
Custom Service Handler Audit Event Configuration
The requirements around what data to capture in these audit events is going to vary, this example is one way to do it but there is a lot of flexibility in what can be added or removed from the event along with how the event is structured. A key consideration will be around what data is available at the time of the event.
This example will be show overriding the handler used by the AmService
so that the traffic going to AM can be intercepted when the response is returned and a custom audit event generated as a result.
Custom Schema Example
As-per the IG documentation, this file lives in the audit-schemas
directory, we will call it service.json
{
"schema": {
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "service",
"type": "object",
"properties": {
"_id": {
"type": "string"
},
"timestamp": {
"type": "string"
},
"eventName": {
"type": "string"
},
"transactionId": {
"type": "string"
},
"request": {
"type": "object",
"properties": {
"secure": {
"type": "boolean"
},
"method": {
"type": "string"
},
"path": {
"type": "string"
},
"queryParameters": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "string"
}
}
},
"headers": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
},
"response": {
"type": "object",
"properties": {
"status": {
"type": "string"
},
"statusCode": {
"type": "string"
},
"headers": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "string"
}
}
},
"elapsedTime": {
"type": "integer"
},
"elapsedTimeUnits": {
"type": "string"
}
}
}
}
},
"filterPolicies": {
"field": {
"includeIf": [
"/_id",
"/timestamp",
"/eventName",
"/transactionId",
"/request",
"/response"
]
}
},
"required": [
"_id",
"timestamp",
"transactionId",
"eventName"
]
}
Custom Script Example
As-per the IG documentation, this file lives in the scripts/groovy
directory, we will call it audit-service.groovy
import static org.forgerock.json.resource.Requests.newCreateRequest
import static org.forgerock.json.resource.ResourcePath.resourcePath
import static org.forgerock.http.protocol.Status.Family.CLIENT_ERROR
import static org.forgerock.http.protocol.Status.Family.SERVER_ERROR
import org.forgerock.json.resource.CreateRequest
import org.forgerock.services.context.RequestAuditContext
import java.time.Duration
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit;
import java.util.concurrent.TimeUnit
// Helper functions
def String transactionId() {
// To cover the case when there is no TXID value in the context
(contexts.transactionId == null) ? context.id : contexts.transactionId.transactionId.value
}
def String formatTimestamp(Instant timestamp) {
return timestamp.atZone(ZoneOffset.UTC).truncatedTo(ChronoUnit.MILLIS).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME);
}
def JsonValue auditEvent(Instant timestamp) {
json(object(field('eventName', "SERVICE-ACCESS"),
field('transactionId', transactionId()),
field('timestamp', formatTimestamp(timestamp))))
}
def CreateRequest auditEventRequest(String topicName, JsonValue auditEvent) {
return newCreateRequest(resourcePath("/" + topicName), auditEvent)
}
def String responseStatus(Status status) {
switch (status.getFamily()) {
case CLIENT_ERROR:
case SERVER_ERROR:
return "FAILED"
default:
return "SUCCESSFUL"
}
}
def requestEvent(Request request) {
object(field('secure', request.uri.scheme.equalsIgnoreCase('https')),
field('path', request.uri.path),
field('method', request.method),
field('queryParameters', request.queryParams),
field('headers', request.headers.copyAsMultiMapOfStrings()))
}
def responseEvent(Response response, Instant startInstant) {
object(field('status', responseStatus(response.status)),
field('statusCode', response.status.code),
field('headers', response.headers.copyAsMultiMapOfStrings()),
field('elapsedTime', Duration.between(startInstant, clock.instant()).toMillis()),
field('elapsedTimeUnits', TimeUnit.MILLISECONDS))
}
Instant startInstant = clock.instant()
next.handle(context, request).then { response ->
logger.debug("Response received, creating an audit event")
// Build the event now that we have the response
JsonValue auditEvent = auditEvent(startInstant)
auditEvent.add('request', requestEvent(request))
.add('response', responseEvent(response, startInstant))
// Before returning the response, capture the custom audit event
auditService.handleCreate(context, auditEventRequest("service", auditEvent))
.thenOnResult { resourceResponse -> logger.debug("Audit event created") }
.thenOnException { e -> logger.warn("An error occurred while creating the audit event", e) }
return response
}
AuditService Definition
Here we define an AuditService
that will log to the audit
directory using a JsonAuditEventHandler
and configured for the custom service
topic. The audit files will be named as service.audit.json
. Included are a couple of excludeIf
examples which show how to exclude a request and response header from being captured in the audit log.
This would be applied to the heap in either the config.json
or a specific route.
{
"name": "CustomAuditService",
"type": "AuditService",
"config": {
"config": {
"filterPolicies": {
"field": {
"excludeIf": [
"/service/request/headers/iPlanetDirectoryPro",
"/service/response/headers/Set-Cookie"
]
}
}
},
"eventHandlers": [
{
"class": "org.forgerock.audit.handlers.json.JsonAuditEventHandler",
"config": {
"name": "customJson",
"logDirectory": "&{ig.instance.dir}/audit",
"topics": [
"service"
],
"buffering": {
"maxSize": 100000,
"writeInterval": "100 ms"
}
}
}
]
}
}
Custom Handler
Here we create a custom Handler
using a Chain
with one Filter
for the custom Groovy script shown above and the TransactionIdOutboundFilter
which is needed to pass on the transaction ID as a header in the request.
By calling it ForgeRockClientHandler
, we override the default provided by IG which means it will be used automatically by any defined AmService
. It is passed two parameters as arguments, a reference to the custom AuditService
by the name used in the heap along with a reference to the Clock
which is used to generate timestamps and elapsed times.
This would be applied to the heap in either the config.json
or a specific route.
{
"name": "ForgeRockClientHandler",
"type": "Chain",
"config": {
"filters": [
{
"name": "CustomAuditScript",
"type": "ScriptableFilter",
"config": {
"type": "application/x-groovy",
"file": "audit-service.groovy",
"args": {
"auditService": "${heap['CustomAuditService']}",
"clock": "${heap['Clock']}"
}
}
},
"TransactionIdOutboundFilter"
],
"handler": "ClientHandler"
}
}
Audit Log Entry Examples
Putting this all together with an IG configuration, that makes use of either the SSO or CDSSO use-cases, it will generate audit log entries like the following when the protected application is accessed.
Making a sessionInfo request to AM
{
"_id": "9c1e6a5e-e9b8-42e9-b4a9-870d4309043a-58",
"timestamp": "2022-12-21T03:12:50.773Z",
"eventName": "SERVICE-ACCESS",
"transactionId": "9c1e6a5e-e9b8-42e9-b4a9-870d4309043a-56",
"request": {
"secure": false,
"path": "/am/json/realms/root/sessions",
"method": "POST",
"queryParameters": {
"_action": [
"getSessionInfo"
]
},
"headers": {
"Accept-API-Version": [
"protocol=2.1,resource=5.0"
],
"Content-Length": [
"124"
],
"Content-Type": [
"application/json; charset=UTF-8"
],
"X-ForgeRock-TransactionId": [
"9c1e6a5e-e9b8-42e9-b4a9-870d4309043a-56/1"
]
}
},
"response": {
"status": "SUCCESSFUL",
"statusCode": 200,
"headers": {
"Cache-Control": [
"private"
],
"Content-API-Version": [
"resource=5.1"
],
"Content-Length": [
"1270"
],
"Content-Security-Policy": [
"default-src 'none';frame-ancestors 'none';sandbox"
],
"Content-Type": [
"application/json; charset=utf-8"
],
"Cross-Origin-Opener-Policy": [
"same-origin"
],
"Cross-Origin-Resource-Policy": [
"same-origin"
],
"Date": [
"Wed, 21 Dec 2022 03:12:50 GMT"
],
"Expires": [
"0"
],
"Pragma": [
"no-cache"
],
"X-Content-Type-Options": [
"nosniff"
],
"X-Frame-Options": [
"SAMEORIGIN"
]
},
"elapsedTime": 4,
"elapsedTimeUnits": "MILLISECONDS"
}
}
Making a policy evaluate request to AM
{
"_id": "9c1e6a5e-e9b8-42e9-b4a9-870d4309043a-59",
"timestamp": "2022-12-21T03:12:50.778Z",
"eventName": "SERVICE-ACCESS",
"transactionId": "9c1e6a5e-e9b8-42e9-b4a9-870d4309043a-56",
"request": {
"secure": false,
"path": "/am/json/realms/root/policies",
"method": "POST",
"queryParameters": {
"_action": [
"evaluate"
]
},
"headers": {
"Accept-API-Version": [
"protocol=1.0,resource=2.0"
],
"Content-Length": [
"225"
],
"Content-Type": [
"application/json; charset=UTF-8"
],
"X-ForgeRock-TransactionId": [
"9c1e6a5e-e9b8-42e9-b4a9-870d4309043a-56/2"
]
}
},
"response": {
"status": "SUCCESSFUL",
"statusCode": 200,
"headers": {
"Cache-Control": [
"private"
],
"Content-API-Version": [
"resource=2.1"
],
"Content-Length": [
"155"
],
"Content-Security-Policy": [
"default-src 'none';frame-ancestors 'none';sandbox"
],
"Content-Type": [
"application/json; charset=utf-8"
],
"Cross-Origin-Opener-Policy": [
"same-origin"
],
"Cross-Origin-Resource-Policy": [
"same-origin"
],
"Date": [
"Wed, 21 Dec 2022 03:12:50 GMT"
],
"Expires": [
"0"
],
"Pragma": [
"no-cache"
],
"X-Content-Type-Options": [
"nosniff"
],
"X-Frame-Options": [
"SAMEORIGIN"
]
},
"elapsedTime": 7,
"elapsedTimeUnits": "MILLISECONDS"
}
}