From 1bef6e8f99031ee5e58165f845d5b6399e669dbc Mon Sep 17 00:00:00 2001 From: Jimmy Vo Date: Thu, 5 Mar 2026 11:21:17 -0500 Subject: [PATCH 1/8] [PM-29129] Add Policy Update Event README --- .../Policies/PolicyUpdateEvents/README.md | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/README.md diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/README.md b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/README.md new file mode 100644 index 000000000000..579efe2e9d61 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/README.md @@ -0,0 +1,129 @@ +# IPolicyUpdateEvent + +This is the policy update pattern that we want our system’s end state to follow. +This directory contains the interfaces and infrastructure for the policy save workflow used by `IVNextSavePolicyCommand`. +Currently, we’re using `IVNextSavePolicyCommand` to transition from the old `IPolicyValidator` pattern. + +## Overview + +When an organization policy is created or updated, the save workflow runs a series of ordered steps. Each step acts like a hook that a handler may listen to by implementing the particular policy event interface. +Note: If you don’t want to hook into these events, you don’t need to create a handler, and your policy will simply upsert to the database with log events. + +``` +SaveAsync() + │ + ├─ 1. Validate org can use policies + ├─ 2. Validate policy dependencies ← IEnforceDependentPoliciesEvent + ├─ 3. Run policy-specific validation ← IPolicyValidationEvent + ├─ 4. Execute pre-save side effects ← IOnPolicyPreUpdateEvent + ├─ 5. Upsert policy + log event + └─ 6. Execute post-save side effects ← IOnPolicyPostUpdateEvent +``` + +The `PolicyEventHandlerHandlerFactory` resolves the correct handler for a given `PolicyType` and interface at each step. A handler is matched by its `IPolicyUpdateEvent.Type` property. At most one handler of each interface type is permitted per `PolicyType`. + +--- + +## Interfaces + +### `IPolicyUpdateEvent` + +The base interface that all policy event handlers must implement. + +```csharp +public interface IPolicyUpdateEvent +{ + PolicyType Type { get; } +} +``` + +Every handler declares which `PolicyType` it handles via `Type`. All other event interfaces extend this one. + +--- + +### `IEnforceDependentPoliciesEvent` + +Declares prerequisite policies that must be enabled before this policy can be enabled. Also prevents a required policy from being disabled while a dependent policy is active. + +```csharp +public interface IEnforceDependentPoliciesEvent : IPolicyUpdateEvent +{ + IEnumerable RequiredPolicies { get; } +} +``` + +- **Enabling** – Each `PolicyType` in `RequiredPolicies` must already be enabled, otherwise a `BadRequestException` is thrown. +- **Disabling a required policy** – If any other policy has this policy listed as a requirement and is currently enabled, the disable action is blocked. + +--- + +### `IPolicyValidationEvent` + +Runs custom validation logic before the policy is saved. + +```csharp +public interface IPolicyValidationEvent : IPolicyUpdateEvent +{ + Task ValidateAsync(SavePolicyModel policyRequest, Policy? currentPolicy); +} +``` + +Return an empty string to pass validation. Return a non-empty error message to throw a `BadRequestException` and abort the save. + +--- + +### `IOnPolicyPreUpdateEvent` + +Executes side effects **before** the policy is upserted to the database. + +```csharp +public interface IOnPolicyPreUpdateEvent : IPolicyUpdateEvent +{ + Task ExecutePreUpsertSideEffectAsync(SavePolicyModel policyRequest, Policy? currentPolicy); +} +``` + +Typical uses: revoking non-compliant users, removing emergency access grants. +Note: + +--- + +### `IOnPolicyPostUpdateEvent` + +Executes side effects **after** the policy has been upserted to the database. + +```csharp +public interface IOnPolicyPostUpdateEvent : IPolicyUpdateEvent +{ + Task ExecutePostUpsertSideEffectAsync( + SavePolicyModel policyRequest, + Policy postUpsertedPolicyState, + Policy? previousPolicyState); +} +``` + +Typical uses: creating collections, sending notifications that depend on the new policy state. +Note: This is more useful for enabling a policy than for disabling a policy, since when the policy is disabled, there is no easy way to find the users the policy should be enforced on. +--- + +### `IPolicyEventHandlerFactory` + +Resolves the correct handler for a given `PolicyType` and event interface type. + +```csharp +OneOf GetHandler(PolicyType policyType) where T : IPolicyUpdateEvent; +``` + +Returns the matching handler, or `None` if the policy type does not implement the requested interface. Throws `InvalidOperationException` if more than one handler is registered for the same `PolicyType` and interface. + +--- + +## Adding a New Policy Handler + +1. Create a class in `PolicyValidators/` implementing `IPolicyUpdateEvent` and any combination of the event interfaces above. +2. Set `Type` to the appropriate `PolicyType`. +3. Register the class as `IPolicyUpdateEvent` (and the legacy interfaces if needed) in `PolicyServiceCollectionExtensions.AddPolicyUpdateEvents()`. + +No changes to `VNextSavePolicyCommand` or `PolicyEventHandlerHandlerFactory` are required. + + From 85c0f58c153045ad28be93e9b82086ececdf6ae3 Mon Sep 17 00:00:00 2001 From: Jimmy Vo Date: Thu, 5 Mar 2026 11:28:41 -0500 Subject: [PATCH 2/8] [PM-29129] wip --- .../OrganizationFeatures/Policies/PolicyUpdateEvents/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/README.md b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/README.md index 579efe2e9d61..d9ab2a7cd881 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/README.md +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/README.md @@ -84,7 +84,6 @@ public interface IOnPolicyPreUpdateEvent : IPolicyUpdateEvent ``` Typical uses: revoking non-compliant users, removing emergency access grants. -Note: --- @@ -104,6 +103,7 @@ public interface IOnPolicyPostUpdateEvent : IPolicyUpdateEvent Typical uses: creating collections, sending notifications that depend on the new policy state. Note: This is more useful for enabling a policy than for disabling a policy, since when the policy is disabled, there is no easy way to find the users the policy should be enforced on. + --- ### `IPolicyEventHandlerFactory` From 4db2b5c80103fccc1eea1babc043a70ea7bd7fdd Mon Sep 17 00:00:00 2001 From: Jimmy Vo Date: Thu, 5 Mar 2026 16:00:51 -0500 Subject: [PATCH 3/8] [PM-29129] Add an example --- .../Policies/PolicyUpdateEvents/README.md | 65 ++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/README.md b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/README.md index d9ab2a7cd881..a91d5db4fd64 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/README.md +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/README.md @@ -102,6 +102,7 @@ public interface IOnPolicyPostUpdateEvent : IPolicyUpdateEvent ``` Typical uses: creating collections, sending notifications that depend on the new policy state. + Note: This is more useful for enabling a policy than for disabling a policy, since when the policy is disabled, there is no easy way to find the users the policy should be enforced on. --- @@ -124,6 +125,68 @@ Returns the matching handler, or `None` if the policy type does not implement th 2. Set `Type` to the appropriate `PolicyType`. 3. Register the class as `IPolicyUpdateEvent` (and the legacy interfaces if needed) in `PolicyServiceCollectionExtensions.AddPolicyUpdateEvents()`. -No changes to `VNextSavePolicyCommand` or `PolicyEventHandlerHandlerFactory` are required. +Note: No changes to `VNextSavePolicyCommand` or `PolicyEventHandlerHandlerFactory` are required. + +### Example + +`AutomaticUserConfirmationPolicyEventHandler` is a good reference. It requires `SingleOrg`, validates org compliance before enabling, and removes emergency access grants as a pre-save side effect. + +**Step 1 – Create the handler** (`PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs`): + +```csharp +public class AutomaticUserConfirmationPolicyEventHandler( + IAutomaticUserConfirmationOrganizationPolicyComplianceValidator validator, + IOrganizationUserRepository organizationUserRepository, + IDeleteEmergencyAccessCommand deleteEmergencyAccessCommand) + : IPolicyValidationEvent, IEnforceDependentPoliciesEvent, IOnPolicyPreUpdateEvent +{ + public PolicyType Type => PolicyType.AutomaticUserConfirmation; + + // IEnforceDependentPoliciesEvent — SingleOrg must be enabled before this policy can be enabled + public IEnumerable RequiredPolicies => [PolicyType.SingleOrg]; + + // IPolicyValidationEvent: Validates org compliance + public async Task ValidateAsync(SavePolicyModel savePolicyModel, Policy? currentPolicy) + { + var policyUpdate = savePolicyModel.PolicyUpdate + var isNotEnablingPolicy = policyUpdate is not { Enabled: true }; + var policyAlreadyEnabled = currentPolicy is { Enabled: true }; + if (isNotEnablingPolicy || policyAlreadyEnabled) + { + return string.Empty; + } + + return (await validator.IsOrganizationCompliantAsync( + new AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest(policyUpdate.OrganizationId))) + .Match( + error => error.Message, + _ => string.Empty); + } + + // IOnPolicyPreUpdateEvent: Revokes non-compliant users, removes emergency access grants before enabling + public async Task ExecutePreUpsertSideEffectAsync(SavePolicyModel policyRequest, Policy? currentPolicy) + { + var isNotEnablingPolicy = policyRequest.PolicyUpdate is not { Enabled: true }; + var policyAlreadyEnabled = currentPolicy is { Enabled: true }; + if (isNotEnablingPolicy || policyAlreadyEnabled) + { + return; + } + + var orgUsers = await organizationUserRepository.GetManyByOrganizationAsync(policyRequest.PolicyUpdate.OrganizationId, null); + var orgUserIds = orgUsers.Where(w => w.UserId != null).Select(s => s.UserId!.Value).ToList(); + + await deleteEmergencyAccessCommand.DeleteAllByUserIdsAsync(orgUserIds); + } + + // IOnPolicyPostUpdateEvent: No implementation is needed since this handler doesn’t require it. +} +``` + +**Step 2 – Register the handler** in `PolicyServiceCollectionExtensions.AddPolicyUpdateEvents()`: + +```csharp +services.AddScoped(); +``` From 58466d4b9b84e43e83b491e509bd9d6267364d92 Mon Sep 17 00:00:00 2001 From: Jimmy Vo Date: Thu, 5 Mar 2026 16:21:08 -0500 Subject: [PATCH 4/8] [PM-29129] Add Limitations --- .../Policies/PolicyUpdateEvents/README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/README.md b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/README.md index a91d5db4fd64..c56308f9b38e 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/README.md +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/README.md @@ -22,6 +22,14 @@ SaveAsync() The `PolicyEventHandlerHandlerFactory` resolves the correct handler for a given `PolicyType` and interface at each step. A handler is matched by its `IPolicyUpdateEvent.Type` property. At most one handler of each interface type is permitted per `PolicyType`. +--- + +## Limitations + +1. Currently, we don't have a way to keep this whole process idempotent, so if there is an exception at any point that is not being handled, the state will stay where the process failed. + + + --- ## Interfaces From 160db08f19b89fc4bb814d859af840d8adb2e77e Mon Sep 17 00:00:00 2001 From: Jimmy Vo Date: Fri, 6 Mar 2026 10:45:21 -0500 Subject: [PATCH 5/8] [PM-29129] Add IPolicyValidator --- .../Policies/PolicyUpdateEvents/README.md | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/README.md b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/README.md index c56308f9b38e..2f69ebb0d7b9 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/README.md +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/README.md @@ -4,6 +4,8 @@ This is the policy update pattern that we want our system’s end state to follo This directory contains the interfaces and infrastructure for the policy save workflow used by `IVNextSavePolicyCommand`. Currently, we’re using `IVNextSavePolicyCommand` to transition from the old `IPolicyValidator` pattern. +--- + ## Overview When an organization policy is created or updated, the save workflow runs a series of ordered steps. Each step acts like a hook that a handler may listen to by implementing the particular policy event interface. @@ -150,7 +152,7 @@ public class AutomaticUserConfirmationPolicyEventHandler( { public PolicyType Type => PolicyType.AutomaticUserConfirmation; - // IEnforceDependentPoliciesEvent — SingleOrg must be enabled before this policy can be enabled + // IEnforceDependentPoliciesEvent: SingleOrg must be enabled before this policy can be enabled public IEnumerable RequiredPolicies => [PolicyType.SingleOrg]; // IPolicyValidationEvent: Validates org compliance @@ -198,3 +200,33 @@ services.AddScoped RequiredPolicies { get; } + Task ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy); + Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy); +} +``` + +--- + +## Reason for transition + +1. IPolicyValidator combines dependency enforcement (RequiredPolicies), validation (ValidateAsync), and pre-save side effects (OnSaveSideEffectsAsync) into a single flat interface. This makes it awkward to add a post-save side effect, since that must be executed after the policy is saved, which lives in a different abstraction. +2. The request body has also expanded, and we need to support metadata that the server needs to perform operations, but that data is not intended to be saved with the policy. +3. By breaking each event hook into a separate interface, it reduces boilerplate, and new hooks can be added without affecting existing services. + +--- + +## During the transition + +1. New policies should implement **both** `IPolicyValidator` and the appropriate `IPolicyUpdateEvent` sub-interfaces so they work correctly regardless of which save path is called. Once `ISavePolicyCommand` is fully replaced by `IVNextSavePolicyCommand` and removed, the `IPolicyValidator` implementation can be dropped. +2. Previous implementations of IPolicyValidator classes have a postfix of `Validator`, but once we move to `IPolicyUpdateEvent`, they should be renamed to `Handler`. This will reduce confusion since validation normally implies there are no write operations, but there are in this context. + +--- From 07a888b3a6a30ea6b40ac0313598e6907d380308 Mon Sep 17 00:00:00 2001 From: Jimmy Vo Date: Fri, 6 Mar 2026 10:57:27 -0500 Subject: [PATCH 6/8] [PM-29129] wip --- .../Policies/PolicyUpdateEvents/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/README.md b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/README.md index 2f69ebb0d7b9..6ca12b311e8c 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/README.md +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/README.md @@ -9,11 +9,11 @@ Currently, we’re using `IVNextSavePolicyCommand` to transition from the old `I ## Overview When an organization policy is created or updated, the save workflow runs a series of ordered steps. Each step acts like a hook that a handler may listen to by implementing the particular policy event interface. + Note: If you don’t want to hook into these events, you don’t need to create a handler, and your policy will simply upsert to the database with log events. ``` SaveAsync() - │ ├─ 1. Validate org can use policies ├─ 2. Validate policy dependencies ← IEnforceDependentPoliciesEvent ├─ 3. Run policy-specific validation ← IPolicyValidationEvent @@ -28,7 +28,7 @@ The `PolicyEventHandlerHandlerFactory` resolves the correct handler for a given ## Limitations -1. Currently, we don't have a way to keep this whole process idempotent, so if there is an exception at any point that is not being handled, the state will stay where the process failed. +1. We don't have a way to keep this whole process idempotent, so if there is an exception at any point that is not being handled, the state will stay where the process failed. From e8d4dc23a7eecbb194b45a0ebf216692ad82a16c Mon Sep 17 00:00:00 2001 From: Jimmy Vo Date: Fri, 6 Mar 2026 11:28:43 -0500 Subject: [PATCH 7/8] [PM-29129] Add New Interface Section --- .../Policies/PolicyUpdateEvents/README.md | 34 ++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/README.md b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/README.md index 6ca12b311e8c..40fb3fb867f0 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/README.md +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/README.md @@ -62,8 +62,8 @@ public interface IEnforceDependentPoliciesEvent : IPolicyUpdateEvent } ``` -- **Enabling** – Each `PolicyType` in `RequiredPolicies` must already be enabled, otherwise a `BadRequestException` is thrown. -- **Disabling a required policy** – If any other policy has this policy listed as a requirement and is currently enabled, the disable action is blocked. +- **Enabling**: Each `PolicyType` in `RequiredPolicies` must already be enabled, otherwise a `BadRequestException` is thrown. +- **Disabling a required policy**: If any other policy has this policy listed as a requirement and is currently enabled, the disable action is blocked. --- @@ -141,7 +141,7 @@ Note: No changes to `VNextSavePolicyCommand` or `PolicyEventHandlerHandlerFactor `AutomaticUserConfirmationPolicyEventHandler` is a good reference. It requires `SingleOrg`, validates org compliance before enabling, and removes emergency access grants as a pre-save side effect. -**Step 1 – Create the handler** (`PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs`): +**Step 1: Create the handler** (`PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs`): ```csharp public class AutomaticUserConfirmationPolicyEventHandler( @@ -193,12 +193,38 @@ public class AutomaticUserConfirmationPolicyEventHandler( } ``` -**Step 2 – Register the handler** in `PolicyServiceCollectionExtensions.AddPolicyUpdateEvents()`: +**Step 2: Register the handler** in `PolicyServiceCollectionExtensions.AddPolicyUpdateEvents()`: ```csharp services.AddScoped(); ``` +--- + +## Adding a New Event Interface + +Use this when the existing interfaces don't cover your use case and you need a new hook in the save workflow. + +### Step 1: Define the interface in `PolicyUpdateEvents/Interfaces/`: + +```csharp +public interface IMyNewEvent : IPolicyUpdateEvent +{ + Task ExecuteMyNewEventAsync(SavePolicyModel policyRequest, Policy? currentPolicy); +} +``` + +It must extend `IPolicyUpdateEvent`. + +### Step 2: Add a step to `SavePolicyCommand.SaveAsync()` or `VNextSavePolicyCommand.SaveAsync()` during transition + +1. Call your method at the appropriate position in the workflow +2. You can use the existing `ExecutePolicyEventAsync` helper or have your method use `policyEventHandlerFactory` directly to retrieve the handlers. +3. **Note on cross-policy logic:** `IEnforceDependentPoliciesEvent` is a special case. It scans *all* registered handlers (not just the targeted policy's handler) to find dependents when disabling a policy. If your new interface requires similar cross-policy scanning, you will need to add that logic directly to `SavePolicyCommand` or `VNextSavePolicyCommand.SaveAsync()` during transition rather than using `ExecutePolicyEventAsync`. + +### Step 3: Document the interface in the [Interfaces](#interfaces) section of this README and add it to the workflow diagram. + +--- # IPolicyValidator (Legacy) From e61e08d442d308105ae246a81237bfc1ae161616 Mon Sep 17 00:00:00 2001 From: Jimmy Vo Date: Fri, 6 Mar 2026 11:41:12 -0500 Subject: [PATCH 8/8] [PM-29129] wip --- .../Policies/PolicyUpdateEvents/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/README.md b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/README.md index 40fb3fb867f0..062f9cef2553 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/README.md +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/README.md @@ -244,7 +244,7 @@ public interface IPolicyValidator ## Reason for transition -1. IPolicyValidator combines dependency enforcement (RequiredPolicies), validation (ValidateAsync), and pre-save side effects (OnSaveSideEffectsAsync) into a single flat interface. This makes it awkward to add a post-save side effect, since that must be executed after the policy is saved, which lives in a different abstraction. +1. `IPolicyValidator` combines dependency enforcement (`RequiredPolicies`), validation (`ValidateAsync`), and pre-save side effects (`OnSaveSideEffectsAsync`) into a single flat interface. This makes it awkward to add a post-save side effect, since that must be executed after the policy is saved, which lives in a different abstraction. 2. The request body has also expanded, and we need to support metadata that the server needs to perform operations, but that data is not intended to be saved with the policy. 3. By breaking each event hook into a separate interface, it reduces boilerplate, and new hooks can be added without affecting existing services. @@ -253,6 +253,6 @@ public interface IPolicyValidator ## During the transition 1. New policies should implement **both** `IPolicyValidator` and the appropriate `IPolicyUpdateEvent` sub-interfaces so they work correctly regardless of which save path is called. Once `ISavePolicyCommand` is fully replaced by `IVNextSavePolicyCommand` and removed, the `IPolicyValidator` implementation can be dropped. -2. Previous implementations of IPolicyValidator classes have a postfix of `Validator`, but once we move to `IPolicyUpdateEvent`, they should be renamed to `Handler`. This will reduce confusion since validation normally implies there are no write operations, but there are in this context. +2. Previous implementations of `IPolicyValidator` classes have a postfix of `Validator`, but once we move to `IPolicyUpdateEvent`, they should be renamed to `Handler`. This will reduce confusion since validation normally implies there are no write operations, but there are in this context. ---