> 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/lifecycle/provisioning-processors/provisioning-processor-reference.md).

# Provisioning processor reference

The provisioning processor contract — the nine methods, what a processor may read, operation routing, the global scripts, the mutation rules and the user save lock.

Everything a processor script has to obey: the class it extends, the nine methods it must implement, what OpenIAM populates before calling it, which operations reach it, what a mutation does and does not change, and how a failure is reported.

Attaching a processor to a managed system is [Attach a provisioning processor](/administration-guide/lifecycle/provisioning-processors.md). Read that first; this page assumes the row is already in place.

## The base class

Both pre- and post-processors ultimately extend `AbstractUserProvisioningProcessor`:

{% code overflow="wrap" expandable="true" %}

```java
public abstract class AbstractUserProvisioningProcessor<T extends Serializable, E extends OpenIAMAPI> {

    protected abstract void add(T object) throws BasicDataServiceException;
    protected abstract void modify(T object) throws BasicDataServiceException;
    protected abstract void delete(DeleteUserProvisioningRequest object, Login login) throws BasicDataServiceException;
    protected abstract void setPassword(PasswordSync passwordSync, Login login) throws BasicDataServiceException;
    protected abstract void resetPassword(PasswordSync passwordSync, Login login) throws BasicDataServiceException;
    protected abstract void suspend(T object) throws BasicDataServiceException;
    protected abstract void resume(T object) throws BasicDataServiceException;
    protected abstract void disable(T object) throws BasicDataServiceException;
    protected abstract void enable(T object) throws BasicDataServiceException;
}
```

{% endcode %}

Your script extends one of the two concrete bases instead — `AbstractUserProvisioningPreProcessor` or `AbstractUserProvisioningPostProcessor` — which fix `T` to `ProvisionUser` and `E` to `UserProvisionAPI`. Clone a shipped processor rather than writing the declaration yourself: the shipped scripts carry the right `import` lines, and a wrong import is a save-time compile failure.

So in your script the nine methods are:

| Method          | Signature in a user processor                                                                               |
| --------------- | ----------------------------------------------------------------------------------------------------------- |
| `add`           | `protected void add(ProvisionUser object) throws BasicDataServiceException`                                 |
| `modify`        | `protected void modify(ProvisionUser object) throws BasicDataServiceException`                              |
| `delete`        | `protected void delete(DeleteUserProvisioningRequest object, Login login) throws BasicDataServiceException` |
| `setPassword`   | `protected void setPassword(PasswordSync passwordSync, Login login) throws BasicDataServiceException`       |
| `resetPassword` | `protected void resetPassword(PasswordSync passwordSync, Login login) throws BasicDataServiceException`     |
| `suspend`       | `protected void suspend(ProvisionUser object) throws BasicDataServiceException`                             |
| `resume`        | `protected void resume(ProvisionUser object) throws BasicDataServiceException`                              |
| `disable`       | `protected void disable(ProvisionUser object) throws BasicDataServiceException`                             |
| `enable`        | `protected void enable(ProvisionUser object) throws BasicDataServiceException`                              |

{% hint style="warning" %}
All nine are `abstract`, so **you must implement all nine even if you only care about one**. Six take the `ProvisionUser`; `delete` takes a `DeleteUserProvisioningRequest` plus the `Login`; the two password methods take a `PasswordSync` plus the `Login` and never see a `ProvisionUser` argument at all. Copy `prov-helper/LDAPPreProcessor_1.0.groovy` — nine empty overrides with the correct signatures — and fill in the ones you need.
{% endhint %}

The dispatch and the accessors are `final`, and you do not call `init` — OpenIAM calls it, autowiring your instance as part of that, which is why `@Autowired` fields in your script get populated.

### What the processor can read

Twelve `protected final` accessors, all populated before your method is called, plus one injected field. Declare nothing.

| Member                                  | Type                         | What it holds                                                                                                                                                                                                                                                                                                                                                              |
| --------------------------------------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ctx`                                   | `ApplicationContext`         | `@Autowired` Spring context — the escape hatch for any other bean.                                                                                                                                                                                                                                                                                                         |
| `getManagedSystemId()`                  | `String`                     | For a per-managed-system processor, the system it is attached to. For a **global** script this is the managed system of whichever `Login` the operation passed in — on Add and Update that is the primary login, so it is the *default* managed system ID, not the target you are provisioning. When there is no login at all it falls back to the default managed system. |
| `getResourceId()`                       | `String`                     | The resource ID of that managed system, or `null` if it has none. This is the resource whose properties carry `PRE_PROCESS`/`POST_PROCESS`.                                                                                                                                                                                                                                |
| `getOperation()`                        | `UserProvisionAPI`           | Which operation is running. Use this rather than inferring from which method was called.                                                                                                                                                                                                                                                                                   |
| `getUser()`                             | `ProvisionUser`              | The **incoming** user, the left side of the diff. Mutate this in a pre-processor to change what gets saved and provisioned.                                                                                                                                                                                                                                                |
| `getUserBeforeModify()`                 | `ProvisionUser`              | The **stored** user, a copy of the right side of the diff. `null` on an add, because there is no previous state.                                                                                                                                                                                                                                                           |
| `getDiff()`                             | `ProvisionUserObjectDiff`    | The full field-by-field diff the policy map will also evaluate. `isDirty()` on any child diff tells you whether that field actually changed.                                                                                                                                                                                                                               |
| `getUserAttributes()`                   | `Map<String, UserAttribute>` | The **stored** user's attributes, keyed by name — the right side of the diff, so also `null` on an add. For the incoming attributes use `getUser().getUserAttributes()`.                                                                                                                                                                                                   |
| `getPrimaryIdentity()`                  | `Login`                      | The identity this invocation is for. For a per-managed-system processor this is that system's identity; for the global one it is the primary login.                                                                                                                                                                                                                        |
| `getPlaintextPrimaryIdentityPassword()` | `String`                     | The decrypted password for that identity, where the calling operation had one. Never log this.                                                                                                                                                                                                                                                                             |
| `getPasswordSync()`                     | `PasswordSync`               | Only populated by the password operations (`SetPassword`, `ResetPassword`, `SyncPasswordFromSrc`); `null` otherwise.                                                                                                                                                                                                                                                       |
| `getOrganization()`                     | `Organization`               | Not the user's organization. It is the organization whose ID is the system property `org.openiam.affiliation.primary.type.id`, which ships as `100` — the seeded root organization `OpenIAM`. Confirm the current value under **Administration → System Configuration**. Treat it as a tenant handle, not as user data.                                                    |
| `isSendActivationLink()`                | `boolean`                    | The value of the system property `org.openiam.send.user.activation.link` at the time of the call. Ships `true`.                                                                                                                                                                                                                                                            |

{% hint style="warning" %}
**The base class's logger is private and your script cannot see it.** Declare your own logger, or use `println` as all twelve shipped `prov-helper` scripts do. A script that references `log` without declaring it fails to compile, and the Groovy manager reports that on save.
{% endhint %}

### Identifying who submitted the request

The requestor is not on the accessor list above — it is on the `ProvisionUser` itself, which carries four request-provenance accessors inherited from its base type:

| Accessor                  | What it holds                                              |
| ------------------------- | ---------------------------------------------------------- |
| `getRequestorLogin()`     | The login of the principal that submitted the request.     |
| `getRequestorUserId()`    | That principal's user ID.                                  |
| `getRequestClientIP()`    | The client IP the request arrived from.                    |
| `getRequestorSessionID()` | The requestor's session ID, where the caller supplied one. |

This is how a processor distinguishes an interactive administrator change from one submitted by an external application over the REST API: give the application its own service account, and branch on `getRequestorLogin()` matching that account.

{% hint style="warning" %}
**A blank `requestClientIP` or `requestorLogin` becomes the literal string `"NA"` before any processor runs**, on both the add and the update path. A processor therefore never sees `null` for those two; it sees `"NA"`. Test for your service account's login rather than for null, and do not treat `"NA"` as an error condition. You can see this for yourself in the audit record for a request submitted without a requestor — the field reads `NA`, not empty.
{% endhint %}

## The operation set

`UserProvisionAPI` declares 25 values with two flags each: whether the operation supports pre/post-processors, and whether it registers in the audit log.

| Operation                                                                                                                           | Processors | Audited | What it does                                                                                                                                             |
| ----------------------------------------------------------------------------------------------------------------------------------- | ---------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Save`                                                                                                                              | yes        | yes     | Add if `id` is empty, otherwise Update                                                                                                                   |
| `Add`                                                                                                                               | yes        | yes     | Create                                                                                                                                                   |
| `Update`                                                                                                                            | yes        | yes     | Modify                                                                                                                                                   |
| `Delete`                                                                                                                            | yes        | yes     | Deprovision — the connector operation is chosen by `ON_DELETE`                                                                                           |
| `Enable`                                                                                                                            | yes        | yes     | Resume the target account                                                                                                                                |
| `Disable`                                                                                                                           | yes        | yes     | Suspend the target account                                                                                                                               |
| `Lock`                                                                                                                              | yes        | yes     | Suspend — but **no processor is invoked**; see the note in [Attach a provisioning processor](/administration-guide/lifecycle/provisioning-processors.md) |
| `Unlock`                                                                                                                            | yes        | yes     | Resume, same caveat                                                                                                                                      |
| `SetPassword`                                                                                                                       | yes        | yes     | Push a chosen password                                                                                                                                   |
| `ResetPassword`                                                                                                                     | yes        | yes     | Push a generated password                                                                                                                                |
| `Search`                                                                                                                            | no         | yes     | Target search (used by reconciliation and synchronization)                                                                                               |
| `SyncPasswordFromSrc`                                                                                                               | no         | yes     | Propagate a password captured elsewhere; routes to the `resetPassword` processor method                                                                  |
| `DirectDeleteConnectorRequest`                                                                                                      | no         | yes     | Delete a target object OpenIAM has no identity for — orphan cleanup, and reconciliation's unmatched-orphan branch                                        |
| `Login`                                                                                                                             | no         | yes     | Authenticate against the target                                                                                                                          |
| `SendSingleProvisionOperation`                                                                                                      | no         | yes     | Provision one identity on one managed system; this is what carries a downstream stream forward                                                           |
| `TestConnection`                                                                                                                    | no         | no      | Read the stored heartbeat                                                                                                                                |
| `GetMetadata`                                                                                                                       | no         | no      | Ask the connector for its attribute names                                                                                                                |
| `RetryProvisioning`                                                                                                                 | no         | no      | Replay a stored request — see [Recovering a failed request](/administration-guide/lifecycle/configure-provisioning.md#recovering-a-failed-request)       |
| `UserProvisioning`, `BuildManagedSystemViewer`, `AddEvent`, `AddModifyRequest`, `BulkRequest`, `ProvisionUsers`, `DeprovisionUsers` | no         | no      | Internal and bulk entry points                                                                                                                           |

OpenIAM maps them onto connector calls like this:

| Incoming operation                                    | Connector operation                                                                  |
| ----------------------------------------------------- | ------------------------------------------------------------------------------------ |
| `Add`, `Save`, `Update`                               | Save the account on the target — **only when there is something to send**, see below |
| `Disable`, `Lock`                                     | Suspend                                                                              |
| `Enable`                                              | Resume                                                                               |
| `SetPassword`, `ResetPassword`, `SyncPasswordFromSrc` | Reset password                                                                       |
| `Delete`                                              | Delete                                                                               |
| `Login`                                               | Login                                                                                |
| `SendSingleProvisionOperation`                        | Send single provision event                                                          |
| anything else                                         | nothing at all, silently                                                             |

{% hint style="warning" %}
**"Nothing to send" is the quietest gate in the product.** An `Add`, `Save` or `Update` only produces a connector request if the object built from the policy map contains at least one attribute carrying an operation other than `NO_CHANGE`. If every policy-map row produced `NO_CHANGE` — or produced nothing at all, because a script threw and was silently skipped — **no connector request is sent whatsoever**, and there is no error. The audit event still records the operation as done. This is the mechanism behind "OpenIAM says it saved the user and the target never changed".

The check is the `provisionconnectorrequest` index, or a simulation capture: an operation that sent nothing has no request to show. See [Simulation mode](/administration-guide/lifecycle/simulation-mode.md).

There is one useful compensation: the identity's status values `FORCE_ENABLED`, `FORCE_DISABLED` and `FORCE_DELETED` override the gate. `FORCE_ENABLED` on a `Disable` sends `Enable` instead; `FORCE_DELETED` rewrites the operation to `Delete` unconditionally.
{% endhint %}

## Operation to method routing

The operation being provisioned decides which of the nine methods is called. Pre- and post-processors route identically, apart from the failure message.

| Operation       | Method called                                                                                          |
| --------------- | ------------------------------------------------------------------------------------------------------ |
| `Add`           | `add(user)`                                                                                            |
| `Update`        | `modify(user)`                                                                                         |
| `Save`          | `add(user)` when the user has no ID yet, otherwise `modify(user)`. A `null` user is a silent no-op.    |
| `Lock`          | `suspend(user)`                                                                                        |
| `Unlock`        | `resume(user)`                                                                                         |
| `Enable`        | `enable(user)`                                                                                         |
| `Disable`       | `disable(user)`                                                                                        |
| `SetPassword`   | `setPassword(passwordSync, login)`                                                                     |
| `ResetPassword` | `resetPassword(passwordSync, login)`                                                                   |
| `Delete`        | `delete(request, login)`                                                                               |
| anything else   | The operation fails with `OPERATION_NOT_SUPPORTED` and "Preprocess for `<operation>` is not supported" |

The post-processor's message reads `Postprocessor for <operation> is not supported` for user operations, but still says `Preprocess` for password and delete operations — so do not use the message text to tell a pre-processor failure from a post-processor one. Use the operation's audit event, which records which of the two failed.

Those ten are exactly the operations in the table above whose **Processors** column reads yes. The other fifteen — `UserProvisioning`, `GetMetadata`, `BuildManagedSystemViewer`, `AddEvent`, `AddModifyRequest`, `Search`, `BulkRequest`, `RetryProvisioning`, `ProvisionUsers`, `DeprovisionUsers`, `TestConnection`, `SyncPasswordFromSrc`, `DirectDeleteConnectorRequest`, `Login`, `SendSingleProvisionOperation` — never reach a processor under their own name. Do not write a processor branch for one and expect it to run.

{% hint style="danger" %}
**`Lock` and `Unlock` route to `suspend` and `resume`, but the Lock and Unlock operations never call a processor at all** — neither the global script nor the per-managed-system one. Your `suspend` and `resume` methods are therefore unreachable on those two operations. Do not put lockout logic there; confirm it by locking a test user with a processor attached and finding nothing from the processor on the operation's audit entry.
{% endhint %}

`ON_DELETE` changes the *connector* operation, not the processor method. On a Delete your processor is always invoked with `getOperation()` equal to `Delete`, so your `delete` method runs even when the connector is being asked to disable — see [The `ON_DELETE` resource property](/administration-guide/lifecycle/configure-provisioning.md#the-on_delete-resource-property).

## Global provisioning scripts

A different mechanism, in a different place, with a wider blast radius. These are system properties in category **IDM**, edited at **Administration → System Configuration** (`/webconsole/propertyValues`). The category holds nine keys; these are the exact keys and shipped default values:

| Key                                                 | Label in the UI                                 | Shipped default                                             |
| --------------------------------------------------- | ----------------------------------------------- | ----------------------------------------------------------- |
| `org.openiam.idm.preProcessor.groovy.script`        | User Pre-Processor Script                       | `prov-helper/ProvisionServicePreProcessor.groovy`           |
| `org.openiam.idm.postProcessor.groovy.script`       | User Post-Processor Script                      | `prov-helper/ProvisionServicePostProcessor.groovy`          |
| `org.openiam.idm.preProcessor.group.groovy.script`  | Group Pre-Processor Script                      | `prov-helper/ProvisionServiceGroupPreProcessor.groovy`      |
| `org.openiam.idm.postProcessor.group.groovy.script` | Group Post-Processor Script                     | `prov-helper/ProvisionServiceGroupPostProcessor.groovy`     |
| `org.openiam.idm.connector.response.handler.script` | Connector Response Custom Groovy Script Handler | `prov-helper/CustomConnectorResponseListenerHandler.groovy` |
| `org.openiam.idm.eventProcessor.groovy.script`      | IDM Data Validation Script                      | `prov-helper/DefaultProvisionServiceEventProcessor.groovy`  |
| `org.openiam.manage.identities.refresh`             | Refresh Identity Data                           | `sync/identities/refreshIdentity.groovy`                    |
| `org.openiam.ui.terminated.user.report`             | Terminated User Report                          | `reports/GetTerminatedUsersForReport.groovy`                |
| `org.openiam.user.conversion.script`                | User Conversion Script                          | `user-conversion/UserTypeConversion.groovy`                 |

Only `org.openiam.idm.connector.response.handler.script` allows an empty value. The other eight are seeded with `EMPTY_OK = 'N'`, so you replace the shipped script rather than clearing the property.

What each of the first six is:

**User pre- and post-processor.** Loaded on every user provisioning operation and built exactly like a managed-system processor — same base class, same nine methods, same routing. The shipped `ProvisionServicePreProcessor_1.0.groovy` and `ProvisionServicePostProcessor_1.0.groovy` are nine `println` stubs: harmless, and a working template.

**Group pre- and post-processor.** Loaded on every group provisioning operation, with a different base class — `AbstractGroupProvisioningPreProcessor` / `AbstractGroupProvisioningPostProcessor` — declaring only `add(ProvisionGroup)`, `modify(ProvisionGroup)` and `delete(DeleteGroupProvisioningRequest)`. The only group operations that reach them are `Save`, `Add`, `Update`, `Delete` and `DirectDeleteConnectorRequest`.

{% hint style="warning" %}
**A group pre-processor on a `Save` operation always fails the operation, even when your code succeeds.** Your `add` or `modify` runs first and then the operation fails with `OPERATION_NOT_SUPPORTED` and "PreProcess for Save is not supported". Group **post**-processors do not behave this way. Confirm it on your build with a group `Save` and a pre-processor whose body is empty: if the operation still fails, the defect is present. Until it is fixed, put group logic in a post-processor, or use `Add` and `Update` rather than `Save`.
{% endhint %}

**Connector response handler.** Not a provisioning processor: it is read when a connector reply comes back. Extend `DefaultConnectorResponseListenerHandler` and override `void notify(AbstractConnectorResponse connectorResponse, User user, AuditLogBuilder auditLog)` — see `prov-helper/CustomConnectorResponseListenerHandler_1.0.groovy`. Use it for "react to what the target said", which a post-processor cannot do because it runs before the asynchronous replies arrive.

**Data validation script.** Read on the `AddEvent` operation, for users and for groups. Implement `ProvisionServiceEventProcessor`, whose method is `Response process(ProvisionActionEvent event, ProvisionActionTypeEnum type)`, and return a `StringResponse` whose value is `CONTINUE` to proceed or `BREAK` to stop; the shipped `DefaultProvisionServiceEventProcessor_1.0.groovy` succeeds with `CONTINUE` and carries the `BREAK` variant in a comment block. **If the script cannot be instantiated the operation proceeds as though it had returned `CONTINUE`**, logging the exception at warn level in the provisioning service log, so a broken validation script validates nothing. Do not rely on it as a control without a test that proves a `BREAK` still stops the operation.

### Reacting to a connector reply

The connector response handler is the only hook that sees what the managed system actually said, which makes it the place for "tell someone when this target refuses an operation". It is instantiated fresh for every reply — there is no cache, so a slow script is slow on every connector response — and if it cannot be instantiated the provisioning service log says `Cannot instantiate <scriptId>. Using default` at error level and the no-op default runs instead. That log line is the only sign, so check it after attaching the script for the first time.

`notify(response, user, idmAuditLog)` is called for every user response and every reset-password response. The `AuditLogBuilder` it receives is the one the operation is about to save, so anything your script adds to it lands in the audit record — the cheapest proof the script ran.

The reply object gives you `getManagedSystemId()`, `getIdentity()`, `getObjectType()`, `isSuccess()`, `isFailure()`, `getErrorCode()` and `getErrorText()`; the last two are the managed system's own words, which is what makes this hook worth writing. `AuditLogBuilder` gives you `addWarning(String)` and `addInfo(String)`. The base class supplies a usable `protected final Logger log` and `protected ApplicationContext ctx`, unlike a provisioning processor.

{% code overflow="wrap" lineNumbers="true" expandable="true" %}

```groovy
import org.openiam.api.connector.AbstractConnectorResponse
import org.openiam.dto.User
import org.openiam.idm.groovy.DefaultConnectorResponseListenerHandler
import org.openiam.idm.srvc.audit.dto.AuditLogBuilder

class CustomConnectorResponseListenerHandler extends DefaultConnectorResponseListenerHandler {

    private static final String WATCHED_MANAGED_SYS_ID = "<MANAGED_SYSTEM_ID>"

    @Override
    void notify(final AbstractConnectorResponse connectorResponse, final User user,
                final AuditLogBuilder auditLog) {
        if (!WATCHED_MANAGED_SYS_ID.equalsIgnoreCase(connectorResponse.getManagedSystemId())) {
            return
        }
        if (connectorResponse.isSuccess()) {
            if (connectorResponse.getErrorText()) {
                auditLog.addWarning("Target reported a warning: ${connectorResponse.getErrorText()}")
            }
        } else {
            log.warn("Operation failed for ${connectorResponse.getIdentity()}: ${connectorResponse.getErrorText()}")
            auditLog.addWarning("Target reported an error: ${connectorResponse.getErrorText()}")
        }
    }
}
```

{% endcode %}

Note that `getErrorText()` is populated on **success** replies as well, and that is deliberate. For a PowerShell connector, the message a script writes with `Write-Warning` is collected from the PowerShell warning stream and placed in the reply's `errorText` while the reply status stays `SUCCESS`. A message written with `Write-Error`, or a thrown terminating error, instead makes the reply `FAILURE` with `errorCode = FAIL_CONNECTOR` and the error text in the same field. So:

| In the PowerShell script                              | Reply status                            | Where the text lands |
| ----------------------------------------------------- | --------------------------------------- | -------------------- |
| `Write-Warning 'Folder could not be moved'`           | `SUCCESS`                               | `errorText`          |
| `Write-Error 'Folder could not be moved'`, or `throw` | `FAILURE`, `errorCode = FAIL_CONNECTOR` | `errorText`          |
| neither                                               | `SUCCESS`                               | `errorText` empty    |

You do not need a script at all to see a `Write-Warning` in the request history: IDM already adds the text of a successful reply as an audit warning by itself . Write a handler only when you need to do something more than record it.

### Global script or managed-system processor?

Use a **global** script when the rule is about the identity and must hold whatever system is being provisioned: refusing a user with no manager, stamping a derived attribute so every downstream policy map can read it, emitting one audit note per operation. It runs once per operation.

Use a **managed-system** processor when the rule is about one target: shaping a DN before an LDAP create, calling that application's API after the account exists, handling one system's password quirk. It runs once per identity on that system, and the identity is in `getPrimaryIdentity()`.

Do not use a global script for one system with an `if (getManagedSystemId() == ...)` guard. On Add and Update the global script is called with the *primary* login, so `getManagedSystemId()` is the default managed system ID and the guard never matches. Iterate `getUser().getPrincipalList()` yourself, or attach a `PRE_PROCESS` row to that system.

## A complete pre-processor

A managed-system pre-processor for an AD system. On create it refuses users with no default email address, and it stamps a `provisioningSource` attribute onto the incoming user so that a policy-map row of type **IDM value** can pick it up. On modify it does the same stamping only when the email actually changed. Every one of the nine methods is present, because they are all abstract.

{% code overflow="wrap" lineNumbers="true" expandable="true" %}

```groovy
import org.openiam.base.AttributeOperationEnum
import org.openiam.base.ws.ResponseCode
import org.openiam.dto.Login
import org.openiam.dto.UserAttribute
import org.openiam.exception.BasicDataServiceException
import org.openiam.idm.provisioning.preprocessor.user.AbstractUserProvisioningPreProcessor
import org.openiam.provision.dto.PasswordSync
import org.openiam.provision.dto.ProvisionUser
import org.openiam.provision.dto.user.request.DeleteUserProvisioningRequest

class ADPreProcessorExample extends AbstractUserProvisioningPreProcessor {

    private static final String SOURCE_ATTRIBUTE = "provisioningSource"

    @Override
    protected void add(ProvisionUser object) throws BasicDataServiceException {
        if (object.getDefaultEmailAddress() == null) {
            throw new BasicDataServiceException(ResponseCode.INVALID_ARGUMENTS,
                    "Refusing to create an AD account without a default email address")
        }
        stampSource(object, AttributeOperationEnum.ADD)
    }

    @Override
    protected void modify(ProvisionUser object) throws BasicDataServiceException {
        if (getDiff()?.getEmailDiffs()?.any({ it.isDirty() })) {
            stampSource(object, AttributeOperationEnum.REPLACE)
        }
    }

    private void stampSource(ProvisionUser object, AttributeOperationEnum operation) {
        final String value = "${getOperation()}:${getManagedSystemId()}".toString()
        object.addUserAttribute(new UserAttribute(SOURCE_ATTRIBUTE, value, operation))
    }

    // delete, setPassword, resetPassword, suspend, resume, disable and enable follow, each an
    // empty @Override with the signature from the base-class table above. All nine are abstract.
}
```

{% endcode %}

Three things about it that generalise beyond this example:

* The mutation is on the `ProvisionUser` passed into the method, the same object as `getUser()`. In a pre-processor it is picked up: OpenIAM rebuilds the diff *after* the pre-processors have run, on both the add and the update path, and the rebuilt diff is what gets saved, audited and handed to the policy map. In a **post**-processor the same mutation reaches nothing — the save and the connector dispatch have already happened.
* `getDiff()` is the only reliable way to ask "did this change?"; the user's own fields tell you what the value *is*, not whether it moved. Inside a **pre**-processor the diff was built before your changes and before any earlier processor's, so read the user for current values and the diff only for what moved.
* `getOperation()` is available in every method, so one branch can serve `Add` and `Save`.

## Mutating the ProvisionUser safely

A pre-processor mutation is only worth making if the save applies it. Four rules decide that, and each has been the root cause of a "the processor ran, the logs prove it, nothing changed" report.

### Attribute operations decide whether a change is saved

A `UserAttribute`'s operation defaults to `NO_CHANGE`, and the two-argument constructor `new UserAttribute(name, value)` leaves it there. Before saving, OpenIAM removes every attribute whose operation is `null` or `NO_CHANGE` from the incoming user, so those attributes are not merely ignored — they are gone from the object by the time it is saved.

An attribute added with the two-argument constructor is therefore discarded silently: your log line proves the code ran, and the attribute never reaches the database. The check is the user's attribute list on screen after the save.

OpenIAM then applies the survivors by operation: `DELETE` removes the attribute by name, and `ADD` and `REPLACE` both write it by name. Either works whether or not the attribute already exists, so there is no need to look up the current value first.

| What you want                          | Operation to set                                                                    |
| -------------------------------------- | ----------------------------------------------------------------------------------- |
| Create or overwrite an attribute value | `AttributeOperationEnum.ADD` or `AttributeOperationEnum.REPLACE` — identical effect |
| Remove an attribute                    | `AttributeOperationEnum.DELETE`                                                     |
| Leave an attribute alone               | `AttributeOperationEnum.NO_CHANGE`, or simply do not touch it                       |

{% hint style="warning" %}
A user's attributes are held in a map keyed by attribute name, so adding an attribute twice **replaces** the first one, including its operation. One user can never carry two attributes with the same name — so if two pieces of logic both stamp the same attribute, the last one to run wins and the first one's operation is lost. That is worth checking when a global script and a per-system script both touch the same attribute.
{% endhint %}

The same applies to the secondary status. Setting `user.setSecondaryStatus(UserStatusEnum.ACTIVE)` in a pre-processor is how the Webconsole's **Account Status** column comes to read `Active` instead of being blank for an enabled user — see [Status changes and account enablement](/administration-guide/lifecycle/policy-map/policy-map-reference.md#status-changes-and-account-enablement) for what that does to the enablement policy-map row before you decide to do it.

### Entitlements: the diff only sees what you send

Every collection diff is built by iterating the **incoming** collection and looking the stored one up by ID, skipping any element whose operation is `null`. Two consequences for a processor:

* An entitlement present on the stored user but absent from the request produces **no** diff entry, so `getDiff()` cannot tell you that something was removed by omission. Compare `getUser().getRoles()` against `getUserBeforeModify().getRoles()` instead — remembering that `getUserBeforeModify()` is `null` on an add, and confirming that the collection you need was actually loaded on the stored user.
* An entitlement you build by hand and add to the user without setting an operation is invisible to the diff and to the save. The operation defaults to `NO_CHANGE`, so it is not `null` — but `NO_CHANGE` means "do nothing", which is usually not what a processor that just added an entitlement intended. The check is the user's roles or groups on screen after the save; an entitlement your script added that is not there was carrying `NO_CHANGE`.

Never rely on `NO_CHANGE` to mean "keep this". Send the entitlements the user should end up with, each carrying an explicit `ADD`, and the ones to remove carrying `DELETE`.

### Deleting an organization affiliation cascades

Organization affiliations are the one entitlement collection where a `DELETE` does more than remove one row. Before any additions are applied, for each affiliation carrying `DELETE`, OpenIAM:

1. Removes the user's affiliation to that organization.
2. Removes the user's affiliation to **every descendant** of it, recursively and unconditionally.
3. Walks its **ancestors** and removes the user's affiliation to each one that has no other child affiliation left on the user, recursively.

The user's affiliation list after the save is the only place you will see the full extent of it, so read it after the first such change rather than assuming one row went.

{% hint style="danger" %}
Step 3 tests the *stored* affiliation collection, and it runs **before** the request's additions are applied. So replacing one child organization with a sibling in a single modify — `DELETE` the old child, `ADD` the new one, leave the shared parent at `NO_CHANGE` — removes the shared parent: at the moment the ancestor check runs, the old child is gone and the new one has not been applied yet.
{% endhint %}

The fix is configuration, not a workaround: send the parent organization explicitly with operation `ADD` in the same request. Affiliations are de-duplicated by organization ID and the operation forced to `ADD`, so re-adding an organization the user already has is safe.

OpenIAM will normally do this for you. When the system property `org.openiam.automatic.organization.hierarchy.assignment.enabled` is true — it ships `true` as of 4.2.1.10, in the `Organization` category, and you can confirm the current value under **Administration → System Configuration** — OpenIAM runs between the delete cascade and the additions, finds the parents of every affiliation carrying `ADD`, and adds them too, recursively.

{% hint style="warning" %}
That automatic re-add **overwrites the affiliation's start date, end date and description with `null`.** If your affiliations carry dates, re-add the ancestor yourself with the values you want rather than letting the automatic path do it, and check the dates on the user's affiliation list afterwards.
{% endhint %}

### Setting an access right on every entitlement

Access rights on a membership are part of the xref, not of the role or group, so a pre-processor is the right place to stamp one onto every entitlement a request carries. `addAccessRight` takes the access right's ID and works the same way on a role membership and on a group membership; call it from `add` and from `modify`, and because the pre-processor's mutation happens before the diff is rebuilt, the stamped rights are what gets saved and audited. The shipped `IS_CERTIFIED` right has the literal ID `IS_CERTIFIED` — find it under **Access Control → Access Rights**, and use the ID shown there for any right you added yourself.

The shipped synchronization transformation scripts do the same thing on the inbound path: `AbstractTransformScript` exposes `protected final Set<String> CERTIFIED_RIGHT_SET`, and `sync/user/ad/ADPowerShellTransformation_1.0.groovy` passes it to `addUserGroupByAttribute`. Whether an access certification campaign requires that right is a certification-configuration question and release-dependent; confirm against your release rather than assuming.

## What a failure return does

There is no return value. A processor signals failure by throwing `BasicDataServiceException`, which propagates out of the operation and is caught once, at the top. In that one place OpenIAM:

* Returns a failure to the caller, with the exception's response code as `errorCode` and your message as `errorText`, plus the full stack trace and root cause.
* Marks the operation's audit event failed, and records your message as the failure reason together with the exception and the response code — so the failure is attributable, and your message is what an auditor reads.
* Leaves the request in the `provisionrequest` index saved but **without a status**, because a status is only recorded when a response came back. A request with no status in that index is a request whose processor threw.

Any other exception type is logged as `Exception Occurred during RabbitMQ Call` and returned as `INTERNAL_ERROR` with the exception's message — so a `NullPointerException` in your script surfaces as a generic internal error, not a diagnosable one. Throw `BasicDataServiceException` with a real message.

### Constructing `BasicDataServiceException`

`BasicDataServiceException` has five constructors, four of which take a second argument. Which one you pick decides what the administrator sees on the failure, so it is not an arbitrary choice:

| Constructor                               | What it populates                                           |
| ----------------------------------------- | ----------------------------------------------------------- |
| `(ResponseCode)`                          | The response code, and nothing else.                        |
| `(ResponseCode, String responseValue)`    | The code, `super(message)`, **and `responseValue`**.        |
| `(ResponseCode, Exception e)`             | The code and `super(e)`. `responseValue` stays `null`.      |
| `(ResponseCode, Throwable originalCause)` | The code and `originalCause`. `responseValue` stays `null`. |
| `(ResponseCode, EsbErrorToken)`           | The code and one error token. `responseValue` stays `null`. |

Two things follow from that table.

**Only the `String` overload sets `responseValue`,** and `responseValue` is what becomes the response's `errorText` and the failure reason on the audit record. Throwing `new BasicDataServiceException(code, e)` therefore produces a failed operation whose audit record carries a response code and an **empty** failure reason. Always pass a string.

**A `null` second argument does not compile at runtime in Groovy.** Groovy resolves overloads by the runtime type of the argument, and `null` matches `String`, `Throwable` and `EsbErrorToken` equally:

{% code overflow="wrap" %}

```
Ambiguous method overloading for method org.openiam.exception.BasicDataServiceException#<init>. Cannot resolve which method to invoke for [class org.openiam.base.ws.ResponseCode, null] due to overlapping prototypes between: [class org.openiam.base.ws.ResponseCode, class org.openiam.exception.EsbErrorToken] [class org.openiam.base.ws.ResponseCode, class java.lang.Throwable] [class org.openiam.base.ws.ResponseCode, class java.lang.String]
```

{% endcode %}

There is no `null` literal in the offending script. The usual source is `e.getMessage()` in a `catch` block: `getMessage()` returns `null` for an exception thrown without one — a bare `NullPointerException` or `RuntimeException` — so the same line works for months and then fails the first time an unexpected exception type reaches it. The operation fails with a message about constructor overloading rather than about the real fault.

Guard the argument with the Elvis operator in **every** `catch` block of every processor script:

{% code overflow="wrap" %}

```groovy
} catch (final Exception e) {
    throw new BasicDataServiceException(ResponseCode.FAIL_PREPROCESSOR,
            e.getMessage() ?: e.getClass().getSimpleName())
}
```

{% endcode %}

`FAIL_PREPROCESSOR` and `FAIL_POSTPROCESSOR` exist for exactly this, alongside `FAIL_PREPROCESSOR_CUSTOM_ERROR_1` through `_3` if you need to distinguish your own failure reasons downstream. Whichever you throw appears as the response code on the operation's audit entry, which is where you confirm the script is the thing that failed.

Where in the operation the throw lands decides how much has already happened:

| Thrown from                             | Already done                                          | Not done                                                 |
| --------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------- |
| Global pre-processor                    | Primary login and password generated                  | Business rules, validation, save, connector requests     |
| Per-system `PRE_PROCESS`                | The above, plus the global pre-processor              | Business rules, validation, save, connector requests     |
| Global post-processor (Add, Update)     | Everything: user saved, connector requests dispatched | The per-system post-processors, access-request workflows |
| Per-system `POST_PROCESS` (Add, Update) | Everything, including the global post-processor       | Access-request workflows initiated from business rules   |

A post-processor throw is therefore a *reporting* failure, not a rollback. If the work must be all-or-nothing, do it in the pre-processor.

## Nested provisioning and the user save lock

A processor must never start a provisioning operation. Mutate the object you were given and return; the platform saves it. Calling a save or a provisioning API from inside a processor, a transformation script or a policy-map script starts a second operation while the first still holds the user's save lock, and the second one fails.

Every user save takes a distributed lock named `USER_SAVE_LOCK_<userId>`, and does not wait for it. When the lock is already held the save fails immediately with:

{% code overflow="wrap" %}

```
BasicDataServiceException [code=ANOTHER_SAVE_IN_PROGRESS, responseValue=null, originalCause=null, errorTokenList=[...]]
```

{% endcode %}

with the token text:

{% code overflow="wrap" %}

```
User with id 8a0a2df688499f4001884a4ad08f596b is saving in another thread. Current request will be sent back to the queue to be retried.
```

{% endcode %}

The lock name is keyed on the user ID, and a create — where the ID is still `null` — gets a random lock name instead, so creates never contend with each other. Only operations on the **same existing user** collide.

{% hint style="danger" %}
**Whether the request is retried depends on who called the save.** A save submitted directly is re-queued and retried up to `org.openiam.rabbitmq.message.max.retry.count` times, which ships as `100`, noting `Save is in progress. Send message back to <queue> queue. RabbitMQ exchange is <exchange>` on the audit record each time, and finally `Maximum retry count exceeded. Operation interrupted. Message queue is <queue>. RabbitMQ exchange is <exchange>`. A save that came **from provisioning** is not re-queued at all — the provisioning operation simply fails with `ANOTHER_SAVE_IN_PROGRESS`. That is why a synchronization-driven `PROVISIONING_MODIFY` shows this error as a hard failure with no retry, and why the user's own history shows no second save that could have collided: the colliding save was started by the operation's own script.
{% endhint %}

The reports that reach support all have the same shape:

| What the script does                                                         | Why it fails                                                                                                                                                                      |
| ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Saves the `ProvisionUser` it was handed                                      | The outer operation already holds that user's lock and will save the object itself when the processor returns.                                                                    |
| Provisions a *second* user from inside the first user's operation            | Legitimate in principle, but it must be asynchronous and it must not be the same user. A synchronous nested cycle on the same user always deadlocks against the lock.             |
| Runs on several nodes for the same user at once, driven by a synchronization | Two nodes taking work for the same user is exactly what the lock exists to prevent. The loser fails. Look at the synchronization's threading and its match rule, not at the lock. |

So the rule for every processor method: **change the object, do not save it.** The mutation is picked up because OpenIAM rebuilds the diff after the pre-processors have run on both the add and the update path, and the rebuilt diff is what gets saved, audited and handed to the policy map.

The same discipline applies to policy-map scripts, which share their instance across users and threads — see [Instance lifecycle: one cached instance per script](/administration-guide/lifecycle/policy-map/policy-map-reference.md#instance-lifecycle-one-cached-instance-per-script).

## Advanced symptoms

The failures that come from the contract on this page rather than from the attachment itself. The attachment failures are on [Attach a provisioning processor](/administration-guide/lifecycle/provisioning-processors.md#when-it-goes-wrong).

| Symptom                                                                                                                                                                                          | Cause                                                                                                                                                                                                                           | Fix                                                                                                                                                                                    |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `PreProcess for Save is not supported` on a group operation, after the logic ran                                                                                                                 | A group pre-processor always fails a `Save`, after your `add` or `modify` has already run.                                                                                                                                      | Not configurable. Move the logic to a group post-processor, or use `Add` and `Update` rather than `Save`.                                                                              |
| Per-system script runs, global does not                                                                                                                                                          | The request carried `skipPreprocessor` or `skipPostProcessor`.                                                                                                                                                                  | Those flags are set by the caller, not by configuration — check the integration that submitted the request.                                                                            |
| An attribute the processor sets is never stored, and the processor's own log line proves it ran                                                                                                  | The `UserAttribute` carries `NO_CHANGE`, which is the default and what the two-argument constructor leaves; those are stripped before the save.                                                                                 | Use `new UserAttribute(name, value, AttributeOperationEnum.ADD)`. See [Attribute operations decide whether a change is saved](#attribute-operations-decide-whether-a-change-is-saved). |
| A guard on `getRequestorLogin()` or `getRequestClientIP()` for `null` never matches                                                                                                              | The blank value was replaced with the literal string `"NA"` before any processor ran.                                                                                                                                           | Test for the login you expect, or for `"NA"`.                                                                                                                                          |
| Operation fails with `Ambiguous method overloading for method org.openiam.exception.BasicDataServiceException#<init>` — the message is verbatim, and the class name in it is part of the message | A `catch` block passed `e.getMessage()` as the second argument and the caught exception had no message, so the argument was `null` and Groovy could not choose between the `String`, `Throwable` and `EsbErrorToken` overloads. | Use `e.getMessage() ?: e.getClass().getSimpleName()`. See [Constructing `BasicDataServiceException`](#constructing-basicdataserviceexception).                                         |
| A processor failure is recorded with a response code but an empty failure reason                                                                                                                 | The exception was built with the `Exception`, `Throwable` or `EsbErrorToken` overload, none of which populates `responseValue` — and `responseValue` is what becomes `errorText` and the audit failure reason.                  | Use the `(ResponseCode, String)` constructor.                                                                                                                                          |
| `ANOTHER_SAVE_IN_PROGRESS`, with `User with id <userId> is saving in another thread. Current request will be sent back to the queue to be retried.` and no second save in the user's history     | The script started a nested provisioning or save operation on the user the outer operation is already saving. A save called from provisioning is not re-queued, so it fails outright.                                           | Mutate the object and return. See [Nested provisioning and the user save lock](#nested-provisioning-and-the-user-save-lock).                                                           |
| `Maximum retry count exceeded. Operation interrupted. Message queue is <queue>. RabbitMQ exchange is <exchange>`                                                                                 | A directly submitted save was re-queued `org.openiam.rabbitmq.message.max.retry.count` times — ships `100` — and never got the user's lock.                                                                                     | Find what holds the lock for that long; usually a nested cycle or a synchronization running many threads over the same user.                                                           |
| Deleting a child organization affiliation also removes the user's parent organization                                                                                                            | Each ancestor that has no remaining child affiliation on the user is removed too, and that cascade runs before the request's additions are applied.                                                                             | Send the parent explicitly with operation `ADD` in the same request. See [Deleting an organization affiliation cascades](#deleting-an-organization-affiliation-cascades).              |
| An affiliation loses its start date, end date or description after an unrelated modify                                                                                                           | Automatic organization-hierarchy assignment re-added it with no dates and no description.                                                                                                                                       | Re-add the ancestor yourself with the dates you want, or turn off `org.openiam.automatic.organization.hierarchy.assignment.enabled`.                                                   |
| Connector response handler does nothing; the provisioning service log shows `Cannot instantiate <scriptId>. Using default` at error                                                              | The value in `org.openiam.idm.connector.response.handler.script` is not a resolvable script ID, or the class does not extend `DefaultConnectorResponseListenerHandler`.                                                         | Fix the property or the base class. See [Reacting to a connector reply](#reacting-to-a-connector-reply).                                                                               |

## Related

* [Attach a provisioning processor](/administration-guide/lifecycle/provisioning-processors.md) — the task.
* [Policy map reference](/administration-guide/lifecycle/policy-map/policy-map-reference.md) — the per-attribute mechanism these hooks wrap.
* [Configure provisioning](/administration-guide/lifecycle/configure-provisioning.md) — what decides whether a provisioning operation happens at all, and the `ON_DELETE` decision table.
* [Shipped Groovy scripts](/administration-guide/reference/shipped-groovy-scripts.md) — the twelve scripts in `prov-helper/` to start from.
* [System properties](/administration-guide/reference/system-properties.md) — the nine IDM-category script keys in context.
* [Create a Groovy script](/administration-guide/automation/manage-groovy-scripts.md) — saving a script and finding its registry ID.

*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/lifecycle/provisioning-processors/provisioning-processor-reference.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.
