> For the complete documentation index, see [llms.txt](https://docs.openiam.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.openiam.com/administration-guide/governance/sod/sod-detection-and-violations.md).

# SoD detection and violations

Schedule the SoD detection sweep and its violation digest — the un-seeded batch task, the cron presets, the snapshot that makes "new" and "resolved" mean anything, and how to choose recipients for a m

## What this is, and when you need it

A provisioning-time SoD check only ever sees the grant in front of it. It cannot know that a user already held the other side of the conflict before the policy existed, that an overnight synchronization added a conflicting role, that somebody widened the content of a business role last week, or that a 45-rule catalogue was activated this morning against a population of forty thousand people. **Scheduled detection** is the control that answers all four: it re-evaluates the whole population on a cron, diffs the result against the previous run, and emails a named group what is new and what has been resolved.

You need it at adoption — a policy or rule set in `SHADOW` is worth nothing until somebody has counted the conflict population that already exists — and then continuously, because access changes for reasons that never pass through an access request. What this control does **not** do is remediate anything. Working the resulting list is [Work an SoD violation to closure](/administration-guide/governance/sod/sod-triage-remediation-and-evidence.md).

## At a glance

|                          |                                                                                                                                                              |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **You will end up with** | A `BATCH_CONFIG` row running the detection sweep on a cron, and a digest mail naming new and resolved violations arriving at a named recipient list          |
| **Where**                | **Access Control → Segregation of Duties → Scheduled Detection** (`/webconsole/segregation-of-duties`)                                                       |
| **You need first**       | At least one active `SOD_POLICY` or a rule set in `SHADOW`; a named SoD operations group to receive the mail; `org.openiam.webconsole.defaultBaseDomain` set |
| **Takes**                | 10 minutes to configure. The first sweep takes minutes on a large population                                                                                 |
| **Reversible**           | Yes — untick **Enabled** on the schedule. Detection writes only the violation index and the digest snapshot; it changes no access                            |

## Before you begin

{% hint style="warning" %}
**The task row is not seeded, despite the well-known task id `SOD_VIOLATION_DIGEST`.** Installing OpenIAM creates the `SOD_DIGEST_STATE` table and the digest mail template, but **no `BATCH_CONFIG` row** — in any of the four supported databases. So scheduled SoD detection is off on every fresh install until somebody turns it on, and a runbook that goes looking for `SOD_VIOLATION_DIGEST` will not find it.

Two checks, and do both before you conclude the digest is broken. On **Administration → Batch Tasks** (`/webconsole/batchTaskSearch`), search for a row whose Spring bean is `sodViolationDigestInitializer`: on a default installation there is none. And the **Scheduled Detection** tab itself shows no schedule and no **Last run** until one has been saved.

Saving a schedule on the **Scheduled Detection** tab is what creates the row — with the defaults below, but with a **generated** id rather than `SOD_VIOLATION_DIGEST`. That is why the bean name, not the task id, is what you search on afterwards.
{% endhint %}

## Configure scheduled detection

{% stepper %}
{% step %}

### Add recipients first

On **Scheduled Detection → Digest recipients**, add at least one user, group or address, and **Save**. The screen refuses to run with no recipients (`Add at least one recipient`), and running detection with nowhere to send the result wastes a full-population sweep. Audit action: `SAVE_SOD_DIGEST_CONFIG`.

Recipients accept **Users**, **Groups** (every member receives the digest) and free-text **Email addresses**, validated on save — an address without `@`, or containing whitespace, is rejected with `INVALID_ARGUMENTS` naming the offending value. Send it to a small named SoD operations group; the reasons are under [Choosing recipients safely](#choosing-recipients-safely) below, and they are not a matter of taste.
{% endstep %}

{% step %}

### Set the frequency and enable it

Pick a preset or enter a custom cron, tick **Enabled**, and **Save**. Same audit action. Because the task row may not exist yet, this save is what creates it, with `springBean = 'sodViolationDigestInitializer'`, `springBeanMethod = 'perform'` and the cron you chose.

| Preset    | Label            | Cron              |
| --------- | ---------------- | ----------------- |
| `DAILY`   | Daily (midnight) | `0 0 0 * * ?`     |
| `WEEKLY`  | Weekly (Monday)  | `0 0 0 ? * MON`   |
| `MONTHLY` | Monthly (1st)    | `0 0 0 1 * ?`     |
| `CUSTOM`  | Custom cron      | Whatever you type |

The field is a six-field Spring cron and the screen states the minimum interval is daily. A blank cron is rejected with a bad request and the audit entry records the failure reason `SoD digest cron expression is blank`. Nothing stops a custom expression from running hourly, but a full-population re-evaluation is not an hourly job — the sweep pages every user in the system.

{% hint style="warning" %}
**All four expressions are evaluated in the scheduler's time zone**, which is the JVM time zone of the batch service — not yours, not the browser's, and not necessarily the one the business thinks in. `0 0 0 * * ?` is midnight wherever that service believes it is, so a digest promised as "overnight" can land mid-afternoon for the people who have to act on it, and a monthly digest can fall on the wrong side of a period end. Confirm the deployed time zone before you commit to a time with a control owner, and for a custom cron record several expected next-run timestamps and then check them against the batch task's next-run value on `/webconsole/batchTaskSearch` rather than assuming.
{% endhint %}
{% endstep %}

{% step %}

### Run it once by hand

**Run now** on the **Scheduled Detection** tab (audit action `RUN_SOD_DIGEST`) fires the cycle immediately. The screen answers `Detection run started; the digest will be emailed when it completes` — it does not wait, and it refuses if you have unsaved changes (`Save your changes before running`).

Do this on a Shadow-mode rule set before you enforce anything. The first digest is your backlog count.
{% endstep %}

{% step %}

### Confirm the mail arrived and the snapshot advanced

Check `SOD_DIGEST_STATE.LAST_RUN` moved and `LAST_PAIRS_JSON` is populated, and open the link in the mail. The second run is the one that produces a meaningful new-and-resolved split, because the first has no snapshot to diff against.
{% endstep %}
{% endstepper %}

## Verification

| What you did                            | Evidence                                                                                                                                                                          |
| --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Saved digest recipients or the schedule | `SAVE_SOD_DIGEST_CONFIG` audit action; `SOD_DIGEST_STATE.RECIPIENTS_JSON`; the `BATCH_CONFIG` row on `/webconsole/batchTaskSearch`, found by its Spring bean                      |
| Ran the digest by hand                  | `RUN_SOD_DIGEST` audit action; `SOD_DIGEST_STATE.LAST_RUN` advanced; service log `SoD violation digest: starting detection sweep`                                                 |
| Detection found conflicts               | Documents in the `user-violated-sod-policy` index; `SOFT_SOD_POLICY_VIOLATION_DETECTION` and `HARD_SOD_POLICY_VIOLATION_DETECTION` audit entries                                  |
| Confirmed the digest link works         | The link in the received mail opens the violations screen — which proves `org.openiam.webconsole.defaultBaseDomain` is set correctly, and is not provable from the property alone |
| A hard policy blocked a grant           | The save fails with `SOD_POLICY_VIOLATION` and one `HARD_SOD_POLICY_VIOLATION_DETECTION` entry per violated policy                                                                |

See [Audit actions and retention](/administration-guide/reference/audit-actions-and-retention.md) for retention — note that every `org.openiam.audit.log.prune.days.*` property ships `-1`, and only a positive number of days prunes anything, so this evidence is not being pruned unless somebody changed it. Read the current values under **Administration → System Configuration → System Audit Log** rather than assuming either way.

## When it goes wrong

| Symptom                                                                     | Cause                                                                                                                                  | Fix                                                                                                                                                                                                                         |
| --------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| No digest ever arrives, and no task exists                                  | No `BATCH_CONFIG` row for the digest ships — this is the default state of every fresh install                                          | Save a schedule on the Scheduled Detection tab; that save creates the row                                                                                                                                                   |
| The digest task is not called `SOD_VIOLATION_DIGEST`                        | The row was created through the batch service, which assigns a generated id                                                            | Identify it by `springBean = 'sodViolationDigestInitializer'` instead                                                                                                                                                       |
| Schedule save rejected                                                      | Blank cron; audit failure reason `SoD digest cron expression is blank`                                                                 | Enter a six-field Spring cron or pick a preset                                                                                                                                                                              |
| Every violation reported as new, every run                                  | `SOD_DIGEST_STATE.LAST_PAIRS_JSON` is empty or was cleared                                                                             | Let one full cycle complete to establish the snapshot                                                                                                                                                                       |
| Digest lists only 50 violations                                             | Each table in the mail is capped at 50 rows and the rest is summarized                                                                 | The mail's own *“… and N more new violation(s)”* and *“… and N more resolved violation(s)”* lines give you the true totals; generate the CSV report for the full set                                                        |
| Recipient save rejected                                                     | Address without `@`, or containing whitespace — `INVALID_ARGUMENTS` names the value                                                    | Correct the address                                                                                                                                                                                                         |
| Manager group never notified                                                | `SOD_POLICY.MANAGER_GROUP_ID` is null, or the soft-violation suppression applies (already-known `SOFT` conflicts notify once)          | Set the manager group; expect repeat mail only for `HARD`                                                                                                                                                                   |
| Violations tab is empty although policies exist                             | No detection has run — the policy was activated but nothing swept the population                                                       | **Detect users** on the Violations tab, or **Run now** on Scheduled Detection                                                                                                                                               |
| The digest arrives but its link is dead                                     | `org.openiam.webconsole.defaultBaseDomain` is unset, internal-only, or missing its scheme                                              | Set it to the externally reachable Webconsole base URL and re-run the digest by hand to confirm                                                                                                                             |
| The scheduled run never happens, although the schedule is saved and enabled | The batch task is disabled, the cron is being read in a different time zone than you assumed, or the Batch Task Manager is not running | Check the task on `/webconsole/batchTaskSearch` — its enabled flag and its next-run time — and confirm the scheduler's time zone. Do not create a second task by hand; the tab recreates the shipped one when it is missing |
| The digest was triggered but nothing ever completed                         | The batch task is fire-and-forget: a successful run means the request was sent, not that the sweep and the mail finished               | Follow the ESB log from `SoD violation digest: starting detection sweep`, and use `SOD_DIGEST_STATE.LAST_RUN` as the completion signal rather than the batch task's own status                                              |
| Detection appears to do nothing                                             | Another node holds `REVIEW_SOD_POLICIES_LOCK` and the trigger became a no-op                                                           | Check for a concurrent sweep; the lock is intentional                                                                                                                                                                       |

## Two detection paths, and they are not the same

**Inline, at provisioning time.** On every user save and access grant OpenIAM evaluates the resulting entitlement set, audits `SOFT_SOD_POLICY_VIOLATION_DETECTION` or `HARD_SOD_POLICY_VIOLATION_DETECTION`, writes the violation document, and blocks the save if any conflict is `HARD`. This catches new conflicts as they are created and nothing else.

**Sweep, over the whole population.** A sweep pages every user 500 at a time, evaluates each against the policies, and rewrites the violation index — including removing users who are no longer violators, which is why a sweep can make the Violations tab shorter. It can be scoped to one policy or run across all of them. Only one sweep runs at a time across the cluster, held by a lock named `REVIEW_SOD_POLICIES_LOCK`, so a second trigger while one is running is a no-op rather than a duplicate scan.

The sweep is what finds the backlog. Saving a policy triggers a sweep for that policy automatically. To sweep every policy on demand, use **Detect users** on the **Violations** tab, or run the scheduled digest, which starts with the same sweep.

Violations are stored in the OpenSearch index `user-violated-sod-policy`, one document per violating user carrying `userId`, `login`, `name`, `timestamp` and a `sodPolicies` list of entries with `id`, `name`, `severity`, `managerGroupId`, `riskId`, `riskName`, `policyThreshold` and `mitigatingControlIds`.

{% hint style="warning" %}
Violation entries carry **no per-violation timestamp** — only the document has one. That is why "new since last run" is derived from a stored snapshot rather than from the index, and why the index alone cannot tell you when a specific conflict first appeared. If you need that history, keep the generated reports.
{% endhint %}

## The digest state row

`SOD_DIGEST_STATE` is a **singleton**. Installation inserts the single row `ID = 'SOD_DIGEST'` and OpenIAM only ever updates it — there is no second row, ever, whatever else you configure.

| Column            | Holds                                                           |
| ----------------- | --------------------------------------------------------------- |
| `ID`              | Always `SOD_DIGEST`                                             |
| `RECIPIENTS_JSON` | Users, groups and literal email addresses configured on the tab |
| `LAST_RUN`        | Timestamp shown as *Last run* on the screen                     |
| `LAST_PAIRS_JSON` | The previous run's (user, policy) violation snapshot            |

`LAST_PAIRS_JSON` is the whole mechanism behind "new" and "resolved": diffing the current absolute violation set against that snapshot is the only way to derive a delta, because violation entries carry no timestamps. Clear it and the next digest reports every existing violation as new.

The self-heal is genuinely useful once you know about it. When the tab looks for the task it tries the well-known id first, then falls back to matching a `BATCH_CONFIG` row on `springBean = 'sodViolationDigestInitializer'` and `springBeanMethod = 'perform'` — so a task somebody deleted by hand is found and recreated rather than silently lost. Saving a schedule only ever changes `cronExpression` and `enabled`; it never rewires the bean, so you cannot repoint the task by mistake.

The batch task itself is only a trigger: it asks the ESB to run the digest and returns. The detection sweep, the diff and the email all happen afterwards and out of band, because the sweep can take minutes. **A successful batch run therefore means the request was sent, not that anybody received a digest.** The completion signals are `SOD_DIGEST_STATE.LAST_RUN` advancing and the mail arriving. The task's own defaults, when the tab creates it, are cron `0 0 0 * * ?` and enabled `false`.

## Choosing recipients safely

Send the digest to a small, named SoD operations group rather than to a broad distribution list. Three properties of the recipient model make that more than a preference.

* **Group membership is expanded at send time**, not when you save the configuration. A group with two members today is a forty-person mailing list after a reorganisation, and nothing warns you. Review the membership of any group you nominate on the same cadence as the digest itself.
* **Explicit email addresses bypass user status entirely.** A literal address in `RECIPIENTS_JSON` is not an identity — it has no lifecycle, so it keeps receiving the digest after the person leaves, changes role, or is disabled in OpenIAM. Use literal addresses only for approved non-user recipients such as a control-owner mailbox or a ticket queue, and retire them deliberately, because no joiner or leaver process will do it for you.
* **The mail carries identity data.** Every row names a user, their login, the policy they violate and its risk classification. That is a list of who in your organisation is currently able to commit which fraud, sitting in a mailbox and probably in a backup of that mailbox. Validate external recipients, forwarding rules and retention before you enable the schedule. And note that the link in the mail is a link, not an authorization: a recipient with no Webconsole access has still read the summary.

`RECIPIENTS_JSON` is one global list on a singleton row. There is no per-policy, per-region or per-application routing, so on a large catalogue the digest is a single undifferentiated message to everyone on the list. Send it to a central SoD team and delegate through filtered reports and work queues, and document that boundary rather than implying a routing capability the product does not have.

## The mail templates

Four templates, all in `MAIL_TEMPLATE`. Three go to the policy's manager group at detection time; one is the scheduled digest.

| Template ID              | Sent when                              | Recipients                                         |
| ------------------------ | -------------------------------------- | -------------------------------------------------- |
| `SOD_VIOLATION_SOFT`     | A `SOFT` conflict is detected inline   | `SOD_POLICY.MANAGER_GROUP_ID`                      |
| `SOD_VIOLATION_HARD`     | A `HARD` conflict is detected inline   | `SOD_POLICY.MANAGER_GROUP_ID`                      |
| `SOD_VIOLATION_RESOLVED` | A user who had violations now has none | The manager group of each formerly violated policy |
| `SOD_VIOLATION_DIGEST`   | The scheduled digest cycle completes   | `SOD_DIGEST_STATE.RECIPIENTS_JSON`                 |

`SOD_VIOLATION_SOFT` and `SOD_VIOLATION_HARD` are **not** sent for every detection. OpenIAM suppresses the mail when it already has a violation document for that user and policy — *unless* the severity is `HARD`. So a soft conflict notifies once and then goes quiet, while a hard one notifies on every attempt. The audit entry is written either way, which makes the audit log, not the mailbox, the count of how often a conflict was hit.

`SOD_VIOLATION_DIGEST` is a Groovy `GStringTemplateEngine` template with a real body, not a stub. Its caption is `SoD violation digest: ${totalViolations} violation(s), ${criticalCount} critical` and its binding is `totalViolations`, `totalViolators`, `criticalCount`, `newCount`, `resolvedCount`, `previousRun`, `newViolations[]` and `resolvedViolations[]` (each row exposing `userId`, `userName`, `login`, `policyId`, `policyName`, `riskName`, `severity`), plus `newMore` / `resolvedMore` and `baseUrl`. It prints at most **50 rows** per table and summarizes the remainder as a count, and it links to `<baseUrl>/webconsole/?frameURL=/webconsole/segregation-of-duties`. The notification type is `SOD_VIOLATION_DIGEST`, the same string as the template id.

{% hint style="warning" %}
**`baseUrl` comes from `org.openiam.webconsole.defaultBaseDomain`, and without it the digest's link to the violation screen does not resolve.** Set that property to the externally reachable Webconsole base URL, including the scheme, before you enable the schedule. Unset or wrong, the mail still arrives and its counts are still correct — only the one link a recipient would actually click is broken, which makes it exactly the notification nobody acts on. See [System properties](/administration-guide/reference/system-properties.md).
{% endhint %}

{% hint style="info" %}
`SOD_VIOLATION_DIGEST` is missing from [Shipped mail templates](/administration-guide/reference/shipped-mail-templates.md). That inventory does not yet cover templates added in the `2026.7.x` releases; its newest entry is from `2026.5.1.0`. The template is genuinely present on an installed or upgraded system — confirm it yourself on **notification bodies** at `/webconsole/mailTemplateEditor`, where it appears with HTML on and enabled. Its absence from the reference page is a gap in that page, not a missing template.
{% endhint %}

## Related

* [Work an SoD violation to closure](/administration-guide/governance/sod/sod-triage-remediation-and-evidence.md) — triage, remediation at the source, exemptions, mitigating controls and the CSV report
* [SoD policies and rule sets](/administration-guide/governance/sod/sod-policies-and-rule-sets.md) — the model, the enforcement ladder and the severity override
* [Activate an SoD rule set](/administration-guide/governance/sod/sod-policies-and-rule-sets/activate-an-sod-rule-set.md) — landing a catalogue in Shadow, which is what makes the first digest meaningful
* [Shipped mail templates](/administration-guide/reference/shipped-mail-templates.md) — the three inline violation templates
* [Search indexes](/administration-guide/reference/search-indexes.md) — `user-violated-sod-policy` and how to rebuild it

*Applies to OpenIAM 2026.8.1. Last reviewed 7 August 2026.*


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://docs.openiam.com/administration-guide/governance/sod/sod-detection-and-violations.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
