Audit reporting

Otto Chriek spent six months checking 2,847 deployments by hand before OPA was deployed. He will not do that again. OPA’s decision logging captures every policy evaluation as a structured JSON record: who asked, what they asked for, what the policy decided, and why. These records flow to Graylog via Filebeat, are queryable for compliance reporting, and form the evidentiary basis for Golem Trust’s ISO 27001 certification. Otto generates the monthly compliance report in approximately four minutes; he has timed it. This runbook covers the decision log format, Filebeat forwarding, the Graylog dashboard, and the compliance report generation procedure.

OPA decision logging configuration

Decision logging is configured in /etc/opa/config.yaml on the standalone OPA server. Logs are written to a local file and also sent to a remote service:

decision_logs:
  console: false
  plugin: file

plugins:
  file:
    path: /var/log/opa/decisions.log

services:
  graylog-decisions:
    url: https://graylog.golems.internal:12900/api/inputs/opa-decisions
    credentials:
      bearer:
        token_path: /var/run/opa/graylog-token

Each decision log entry is a JSON object on a single line:

{
  "decision_id": "7a3f2c1d-4b8e-41a2-9f00-bb3e12345678",
  "input": {
    "requestor": "angua@golems.internal",
    "resource": {"name": "royalbank-prod-postgres", "environment": "production"},
    "approvals": [...]
  },
  "result": true,
  "timestamp": "2025-11-14T09:15:32.441Z",
  "path": "golem_trust/strongdm/access",
  "requested_by": "strongdm-gateway-01.golems.internal",
  "metrics": {
    "counter_regovm_eval_op_count": 42,
    "timer_rego_query_eval_ns": 183000
  },
  "erased": ["input/approvals/0/approver_password"]
}

The erased field lists input paths that were stripped before logging. This is configured per policy using the OPA masking feature, to avoid logging sensitive fields such as passwords or tokens.

Masking sensitive fields

Add a masking rule in the decision log configuration to strip fields that must not appear in audit logs:

decision_logs:
  mask_decision: golem_trust/mask/decision

data:
  golem_trust:
    mask:
      decision:
        - "/input/password"
        - "/input/secret_id"
        - "/input/approvals/*/approver_token"

Filebeat forwarding to Graylog

Filebeat runs on each OPA server VM and tails the decision log file. The Filebeat configuration at /etc/filebeat/inputs.d/opa-decisions.yml:

- type: filestream
  id: opa-decisions
  paths:
    - /var/log/opa/decisions.log
  parsers:
    - ndjson:
        target: opa
        add_error_key: true
  fields:
    source: opa-decisions
    environment: production
  fields_under_root: true
  processors:
    - timestamp:
        field: opa.timestamp
        layouts:
          - "2006-01-02T15:04:05.999Z"
        target_field: "@timestamp"

Graylog receives these events on the opa-decisions stream. The stream has a 90-day retention policy, which exceeds the ISO 27001 requirement for audit trail completeness.

Graylog dashboard

The “OPA Policy Decisions” dashboard in Graylog provides the operational view. The key widgets and their queries are:

Allow/deny ratio over time:

source:opa-decisions

Chart by result field, last 24 hours.

Top denied policies (last 7 days):

source:opa-decisions AND result:false

Aggregated by path field, top 10.

Policy decision latency percentiles:

source:opa-decisions

Statistical aggregation on metrics.timer_rego_query_eval_ns, converted to milliseconds.

Decisions by requestor:

source:opa-decisions

Aggregated by requested_by, used to identify services generating unexpected policy queries.

Generating compliance reports

Otto Chriek’s ISO 27001 evidence requirement is: “show me every production deployment in the past 30 days and confirm that each had at least two approvals.” The report is generated by querying Graylog’s REST API and processing the decision log:

#!/usr/bin/env python3
import requests
import json
from datetime import datetime, timedelta

GRAYLOG_URL = "https://graylog.golems.internal:12900"
STREAM_ID   = "opa-decisions-stream-id"
DAYS        = 30

start = (datetime.utcnow() - timedelta(days=DAYS)).strftime("%Y-%m-%dT%H:%M:%SZ")

resp = requests.get(
    f"{GRAYLOG_URL}/api/search/universal/relative",
    params={
        "query": 'source:opa-decisions AND path:golem_trust\\/strongdm\\/access AND result:true',
        "range": DAYS * 86400,
        "limit": 5000,
        "fields": "timestamp,input.requestor,input.resource.name,result,decision_id"
    },
    auth=("otto.chriek", open("/run/secrets/graylog-token").read().strip()),
    headers={"Accept": "application/json"}
)

decisions = resp.json()["messages"]

report = {
    "generated_at": datetime.utcnow().isoformat() + "Z",
    "period_days": DAYS,
    "total_decisions": len(decisions),
    "production_approvals": [
        {
            "timestamp": m["message"]["timestamp"],
            "requestor": m["message"].get("input.requestor"),
            "resource": m["message"].get("input.resource.name"),
            "decision_id": m["message"]["decision_id"]
        }
        for m in decisions
    ]
}

with open(f"compliance-report-{datetime.utcnow().strftime('%Y-%m')}.json", "w") as f:
    json.dump(report, f, indent=2)

print(f"Report generated: {len(decisions)} approved production access decisions in the past {DAYS} days")

Run this script on the first working day of each month. The output JSON file is uploaded to the iso27001-evidence bucket in Hetzner Object Storage by the CI pipeline job compliance:generate-monthly-report.

Monthly summary report format

The report Otto sends to the ISO auditor is a human-readable summary generated from the JSON file. The key sections are:

  • Total policy decisions evaluated in the period

  • Number of denied decisions by policy path

  • Number of approved production deployments with approval counts

  • Any anomalies: decisions where the approval count was exactly the minimum required, flagged for manual review

  • Attestation: “No production deployments occurred outside the policy-controlled pipeline during this period, as evidenced by the OPA decision log retained in Graylog”

Otto notes that producing this report used to take three days. It now takes four minutes, of which three are waiting for the Graylog query to return.