====== Automatic Role Guarantor Transfer ====== When a role guarantor stops being able to act as a guarantor (the identity is deleted or blocked, or one of its contracts is deleted, deactivated or expires), the roles they guaranteed could be left **without any active guarantor**. This feature detects such situations and, **before** the destructive change is persisted, transfers the affected guarantees to a substitute (typically the original guarantor's manager / superior), so that every role keeps an active guarantor. Each new guarantor is then notified about the roles they became a guarantor of and why. The transfer covers both kinds of role guarantee in CzechIdM: * **direct guarantee** (''IdmRoleGuarantee'') - an identity is set directly as a guarantor of a role, * **guarantee role** (''IdmRoleGuaranteeRole'') - the guarantors of a role are all holders of a configured //guarantee role//. //Examples:// * //A team lead who is the sole guarantor of several application roles leaves the company and their identity is deleted - the guarantees move to their manager.// * //An external consultant's contract expires; they were the only holder of a guarantee role used by ten business roles - the guarantee role is reassigned to the manager reachable through that contract.// The feature is **best-effort and non-blocking**: a failure of the transfer (e.g. a misconfigured script) is logged but never aborts the original operation (delete / block / contract change / expiration de-provisioning). ===== Key concepts ===== ==== Last (active) guarantor ==== A **substitute** is added only when removing the original guarantor would actually leave the role unguarded - i.e. the original guarantor is the **last active** one. (A direct guarantee is //additionally// always removed from an identity that becomes inactive, even when it was not the last guarantee - see [[#direct_guarantees|Direct guarantees]].) The "is there still someone else?" check considers only **effective** holders/guarantees: * **Direct guarantee** - another guarantee counts only if its identity is **valid** (state ''VALID'' and not disabled). * **Guarantee role** - another holder counts only if //all// of the following hold: the role assignment is currently valid (''validFrom''/''validTill''), it is held through a **valid contract** (''state'' is ''null'' and the contract is valid by ''validFrom''/''validTill''), and the holding **identity is valid** (state ''VALID'' and not disabled). ==== Effectively active contract ==== A contract is considered //effectively active// when it has **no special state** (not ''EXCLUDED'' / ''DISABLED'') and is **valid now** (''validFrom''/''validTill''). This is the same rule the exclusivity detection uses for "valid holder", and it drives the contract-level triggers. ==== Substitute ==== The identity (or identities) the guarantee is transferred to. Substitutes are resolved through a configurable system script (managers by default), with a robust fallback chain - see [[#how_a_substitute_is_resolved|How a substitute is resolved]]. ===== When the transfer runs ===== The transfer always runs in the **BEFORE** phase of the triggering event (processor order ''CoreEvent.DEFAULT_ORDER - 100''), so the live data needed to detect the last guarantor and to resolve managers still exists. Five event processors cover the situations in which a guarantor becomes inactive: ^ Trigger ^ Event ^ Processor ^ Removes original holding? ^ Reason code ^ | Identity is deleted | Identity ''DELETE'' | ''IdentityGuaranteeTransferDeleteProcessor'' | No - the delete cascade removes it | ''IDENTITY_DELETED'' | | Identity is manually blocked | Identity ''UPDATE'' → ''DISABLED_MANUALLY'' | ''IdentityGuaranteeTransferDisableProcessor'' | Yes - block does not remove role assignments | ''IDENTITY_DISABLED'' | | Active contract is deleted | Contract ''DELETE'' | ''IdentityContractGuaranteeTransferDeleteProcessor'' | No - the delete cascade removes it | ''CONTRACT_DELETED'' | | Active contract is deactivated by an edit (validTill set to the past, validFrom set to the future, set ''EXCLUDED'' or ''DISABLED'') | Contract ''UPDATE'' (active → inactive) | ''IdentityContractGuaranteeTransferDeactivateProcessor'' | Yes - the contract stays in place | ''CONTRACT_DEACTIVATED'' | | Contract expires naturally (its ''validTill'' passes) | Contract ''EXPIRED'' | ''IdentityContractGuaranteeTransferExpiredProcessor'' | No - the expiration task removes it | ''CONTRACT_EXPIRED'' | For contract-level triggers, **direct** guarantees are handled only when the affected contract was the identity's **last** effectively active contract (i.e. the identity as a whole becomes inactive) - the identity is then removed from all roles it directly guarantees, with a substitute added where it was the last guarantee. **Guarantee roles** are always evaluated per the affected contract. Besides these event processors, two nightly long-running tasks drive the same transfer where de-provisioning does **not** go through a contract event: ''HrEndContractProcess'' (natural date-expiry) and ''HrContractExclusionProcess'' (a contract set ''EXCLUDED'' outside the application, e.g. directly in the database) - see [[#natural_contract_expiry|Natural contract expiry]] and the implementation reference. ===== What exactly is transferred ===== ==== Direct guarantees ==== When the identity becomes inactive it is removed as a direct guarantee from **every** role it directly guarantees - an inactive identity must not remain a direct guarantor. This mirrors guarantee roles, which an inactive identity loses together with its role assignments. For the subset of roles where the identity was the **last active** direct guarantee, a substitute is added first so the role keeps an active guarantor: - the substitute(s) are added as a direct guarantee of the role (if not already a guarantee), then - the original identity's direct guarantee is removed. For roles that still have another active direct guarantee, the identity is simply removed - no replacement is needed. **Consistency rule:** when the identity was the last guarantee but no substitute can be resolved, it is **still removed**. The role then ends up with no direct guarantee - the same outcome as a guarantee role left unfilled after its holder loses the role - rather than keeping an inactive guarantor artificially. ==== Guarantee roles ==== For every guarantee role exclusively held by the original identity (resp. through the affected contract): - the guarantee role is assigned to the substitute(s) on the substitute's prime valid contract, via an internal role request executed immediately (without approval), - by gaining the guarantee role, the substitute becomes a guarantor of **all owner roles** that use it, - the original holding is removed when ''removeSource'' applies (see the table above) - direct holdings only; sub-roles are removed together with their direct role. Substitutes for a guarantee role are resolved **per source contract** (the contract through which the original identity held the guarantee role) and cached, so the DB-heavy resolution runs once per source contract rather than once per guarantee role. ===== How a substitute is resolved ===== Substitutes are resolved by a robust fallback chain (''RoleGuaranteeTransferManager.resolveSubstitutes''): - **Configured system script** - run the script configured by ''idm.sec.core.role.guarantee.transferScript'' (default ''roleGuaranteeTransferTargets''), keep only the results that are ''IdmIdentityDto'' **and active** (state ''VALID'', not disabled), and never the identity being removed. If this yields a non-empty list, those are the substitutes. - **Fallback role holders** - otherwise (empty result, wrong-typed result or a script failure), use the **active holders of the fallback role** configured by ''idm.sec.core.role.guarantee.transferFallbackRole'' (the **admin role** when not configured). - **Admin identity** - if there are still none, fall back to the single ''admin'' identity. If not even the admin identity is available, the affected roles are left without an active guarantor and an error is logged. ===== Natural contract expiry ===== Contracts that elapse by date (their ''validTill'' passes without any user action) are de-provisioned by the nightly scheduled tasks. To let the transfer react //before// the contract's role assignments are removed, these tasks **publish the ''EXPIRED'' event** for each genuinely date-expired contract (''state'' is ''null'') right before removing its roles. ''IdentityContractGuaranteeTransferExpiredProcessor'' reacts to that event. Two tasks must cover this, because they reach de-provisioning differently and run at different times: ^ Task ^ Default schedule ^ Role of the EXPIRED publish ^ | ''HrEndContractProcess'' (//End of contract validity//) | 00:50 | The primary task. With no workflow configured it calls ''IdentityContractEndProcessor'' **directly (not via a contract event)**, so without this publish no guarantee-transfer processor would ever fire for a naturally expired contract. Publishes ''EXPIRED'' before processing - **also before starting a configured workflow**. | | ''IdentityContractExpirationTaskExecutor'' (//Remove roles by expired identity contracts//) | 01:00 | Secondary. Publishes ''EXPIRED'' before removing roles. As it runs after ''HrEndContractProcess'', for contracts the HR task already handled it finds nothing left to transfer (idempotent no-op). | The ''EXPIRED'' publish is **skipped on the very first run** of each task (''isFirstRun()''), to avoid transferring guarantees for all historically expired contracts right after deployment. ''HrEndContractProcess'' only publishes for **genuine date-expiry** (''state'' is ''null'' and ''validTill'' in the past); contracts invalid for another reason (future validity, disabled / excluded by state) are skipped - they were handled when their state changed. Publishing is wrapped in its own ''try/catch'' so a transfer failure can never block de-provisioning. ''IdentityContractExpirationEventTriggerTaskExecutor'' also publishes ''EXPIRED''. Running these tasks together is safe: the transfer is **idempotent** (a guarantee/holding already moved is detected and not duplicated), so a repeated ''EXPIRED'' for the same contract simply finds nothing left to do. ===== Notification ===== After a transfer, **one consolidated e-mail per new guarantor** is sent, listing every role they became a guarantor of and the reason. ^ Item ^ Value ^ | Topic | ''core:roleGuaranteeTransferred'' (''CoreModuleDescriptor.TOPIC_ROLE_GUARANTEE_TRANSFERRED'') | | Template | ''roleGuaranteeTransferred'' (''IdmCoreRoleGuaranteeTransferred.xml'', system template, bilingual cs/en) | | Default delivery | e-mail (''IdmEmailLog''), registered in ''CoreModuleDescriptor#getDefaultNotificationConfigurations'') | | Level | ''INFO'' | Template parameters: ^ Parameter ^ Type ^ Description ^ | ''identity'' | ''IdmIdentityDto'' | the new guarantor (recipient) | | ''roles'' | ''List'' | roles the recipient became a guarantor of | | ''originalGuarantee'' | ''IdmIdentityDto'' | the original guarantor who is no longer active | | ''reason'' | ''String'' | one of ''IDENTITY_DELETED'', ''IDENTITY_DISABLED'', ''CONTRACT_DELETED'', ''CONTRACT_DEACTIVATED'', ''CONTRACT_EXPIRED'' | ===== Configuration ===== ^ Property ^ Default ^ Description ^ | ''idm.sec.core.role.guarantee.transferScript'' | ''roleGuaranteeTransferTargets'' | Code of the system script that resolves the substitutes (managers / superiors). | | ''idm.sec.core.role.guarantee.transferFallbackRole'' | //(empty → admin role)// | Role whose active holders are used as substitutes when the script yields none. The admin role is used when left empty. | Both properties are exposed on the **role configuration** agenda (''RoleConfiguration''). ===== System script: roleGuaranteeTransferTargets ===== A ''SYSTEM''-category Groovy script that resolves the managers / superiors to whom a guarantee should be transferred. ^ Parameter ^ Type ^ Description ^ | ''identity'' | ''IdmIdentity'' | identity whose managers are resolved (required) | | ''contract'' | ''IdmIdentityContract'' | optional - when given, only manager(s) reachable through this contract are returned; otherwise all managers of the identity | Returns ''List'' (never ''null''). It uses ''IdmIdentityFilter#setManagersFor'' (all managers) and, when a contract is given, ''IdmIdentityFilter#setManagersByContract'' (managers through that contract). import eu.bcvsolutions.idm.core.api.dto.filter.IdmIdentityFilter; if (identity == null) { return java.util.Collections.emptyList(); } IdmIdentityFilter filter = new IdmIdentityFilter(); filter.setManagersFor(identity.getId()); if (contract != null) { filter.setManagersByContract(contract.getId()); } return identityService.find(filter, null).getContent(); A custom script can be configured instead (e.g. to return a fixed support team). The contract returned for an inactive contract still resolves managers, because ''DefaultManagersFilter'' enforces "valid contract managers" only when explicitly requested. ===== Transaction and ordering behaviour ===== * **BEFORE phase, negative order** (''DEFAULT_ORDER - 100'') - guarantees, contracts and role assignments still exist, so the last guarantor can be detected and managers resolved. * **''REQUIRES_NEW'' transaction** - the public methods of ''RoleGuaranteeTransferManager'' run in their own transaction. Together with the ''try/catch'' in each processor this makes the transfer //best-effort//: it cannot mark the parent (delete / block / expiration) transaction rollback-only. * **Idempotent** - assigning a guarantee/guarantee role that already exists is detected and skipped, so repeated events for the same situation do not duplicate guarantees. ===== Implementation reference ===== ==== Detection ==== ^ Type ^ Member ^ Purpose ^ | ''IdmRoleGuaranteeService'' | ''findRolesWithExclusiveGuarantee(IdmIdentityDto)'' | Roles for which the identity is the last active **direct** guarantee (drives whether a substitute is added). Default method returns an empty list; implemented in ''DefaultIdmRoleGuaranteeService''. | | ''IdmRoleGuaranteeService'' | ''find(filter.setGuarantee(id))'' | All roles where the identity is a direct guarantee (used to remove it from all of them). | | ''IdmRoleGuaranteeRepository'' | ''findExclusiveGuaranteeRoleIds(UUID identityId)'' | JPQL (''NOT EXISTS'' on other valid guarantees) backing ''findRolesWithExclusiveGuarantee''. | | ''IdmRoleGuaranteeRoleRepository'' | ''findExclusivelyHeldGuaranteeRoleIdsByIdentity(UUID, LocalDate)'' | Guarantee roles held //only// by the given identity (across any contract). | | ''IdmRoleGuaranteeRoleRepository'' | ''findExclusivelyHeldGuaranteeRoleIdsByContract(UUID, LocalDate)'' | Guarantee roles held //only// through the given contract. | ==== Transfer ==== ^ Component ^ Responsibility ^ | ''RoleGuaranteeTransferManager'' | Central service. ''transferForRemovedIdentity(...)'' and ''transferForDeactivatedContract(...)'' (both ''REQUIRES_NEW''); direct guarantees removed from all roles (substitute added only for the last-guarantee subset); guarantee-role reassignment; substitute resolution + fallback chain; new-guarantor notification; ''isContractEffectivelyActive(...)''. | | ''IdentityGuaranteeTransferDeleteProcessor'' | Identity ''DELETE''. | | ''IdentityGuaranteeTransferDisableProcessor'' | Identity ''UPDATE'' into ''DISABLED_MANUALLY''. | | ''IdentityContractGuaranteeTransferDeleteProcessor'' | Active contract ''DELETE''. | | ''IdentityContractGuaranteeTransferDeactivateProcessor'' | Active contract ''UPDATE'' (active → inactive). | | ''IdentityContractGuaranteeTransferExpiredProcessor'' | Contract ''EXPIRED''. | | ''HrEndContractProcess'' | Publishes ''EXPIRED'' for genuinely date-expired contracts (''isFirstRun'' guard) before its standard de-provisioning / a configured workflow - the primary natural-expiry hook. | | ''HrContractExclusionProcess'' | For a contract excluded outside the application (''state'' set to ''EXCLUDED'' directly, so no event fired), calls the transfer manager directly (''removeSource = true'', ''isFirstRun'' guard) before de-provisioning. | | ''IdentityContractExpirationTaskExecutor'' | Publishes ''EXPIRED'' (with ''isFirstRun'' guard) before removing roles of expired contracts. | ==== Configuration and resources ==== ^ Artifact ^ Location ^ | Config properties | ''RoleConfiguration'' / ''DefaultRoleConfiguration'' | | System script | ''eu/bcvsolutions/idm/scripts/roleGuaranteeTransferTargets.xml'' | | Notification topic | ''CoreModule.TOPIC_ROLE_GUARANTEE_TRANSFERRED'' + ''CoreModuleDescriptor'' default config | | E-mail template | ''eu/bcvsolutions/idm/templates/IdmCoreRoleGuaranteeTransferred.xml'' | ===== Tests ===== Integration tests (''core-impl'', JUnit 4 + ''TestHelper''): ^ Test class ^ Focus ^ | ''RoleGuaranteeExclusiveDetectionIntegrationTest'' | Detection queries / validity rules. | | ''RoleGuaranteeTransferManagerIntegrationTest'' | Transfer logic and substitute fallback chain, incl. removal of a non-last direct guarantee (without replacement). | | ''RoleGuaranteeTransferNotificationIntegrationTest'' | New-guarantor notification. | | ''RoleGuaranteeTransferIdentityEventIntegrationTest'' | Identity delete / block triggers. | | ''RoleGuaranteeTransferContractEventIntegrationTest'' | Contract delete / deactivate / expire triggers, incl. the expiration LRT and the HR end-contract / exclusion LRT paths. | | ''RoleGuaranteeTransferConfigurationIntegrationTest'' | Config properties, script, template and notification configuration. | ---- //Available since 15.15.5.//