Configuring Identity Gateway to Audit Service Requests using Custom Audit Events

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"
    }
}
3 Likes