diff --git a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/Initialization_Workflow.json b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/Initialization_Workflow.json index 439563b9..2cce2ac5 100644 --- a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/Initialization_Workflow.json +++ b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/Initialization_Workflow.json @@ -73,6 +73,21 @@ "name": "additionalFunctionSources", "type": "Array", "value": [] + }, + { + "name": "googleRequiredUserTargets", + "type": "Array", + "value": [] + }, + { + "name": "googleSkippedUpdateSources", + "type": "Array", + "value": [] + }, + { + "name": "googleUpdateUserBody", + "type": "Object", + "value": {} } ] } @@ -1229,12 +1244,201 @@ }, "type": "Scope" }, - "Response_Initialization_Data": { + "Protect_Google_Required_User_Attributes": { "runAfter": { "Build_SCIM_User_Body": [ "Succeeded" ] }, + "type": "If", + "expression": "@contains(toLower(coalesce(first(body('Filter_BaseAddress_Connectivity_Parameter'))?['value'], '')), 'googleapis.com')", + "actions": { + "Set_Google_Update_User_Body": { + "type": "SetVariable", + "inputs": { + "name": "googleUpdateUserBody", + "value": "@outputs('Compose_Final_Dynamic_Update_User_Body')" + } + }, + "Get_Google_Required_User_Schemas": { + "runAfter": { + "Set_Google_Update_User_Body": [ "Succeeded" ] + }, + "type": "Http", + "inputs": { + "uri": "@concat(parameters('scimEndpoint'), '/Schemas')", + "method": "GET", + "headers": { + "Authorization": "@concat('Bearer ', parameters('scimBearerToken'))", + "Accept": "@parameters('scimContentType')" + } + } + }, + "Parse_Google_Required_User_Schemas": { + "runAfter": { + "Get_Google_Required_User_Schemas": [ "Succeeded" ] + }, + "type": "ParseJson", + "inputs": { + "content": "@body('Get_Google_Required_User_Schemas')", + "schema": { + "type": "object", + "required": [ "Resources" ], + "properties": { + "Resources": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": [ "id", "attributes" ], + "properties": { + "id": { "type": "string" }, + "attributes": { "type": "array" } + } + } + } + } + } + } + }, + "ForEach_Google_User_Target": { + "runAfter": { + "Parse_Google_Required_User_Schemas": [ "Succeeded" ] + }, + "type": "Foreach", + "foreach": "@variables('sourceToTargetLookup')", + "runtimeConfiguration": { + "concurrency": { "repetitions": 1 } + }, + "actions": { + "Compose_Google_Target_Attribute_Path": { + "type": "Compose", + "inputs": "@toLower(if(startsWith(toLower(items('ForEach_Google_User_Target')['target']), 'urn:'), last(split(items('ForEach_Google_User_Target')['target'], ':')), items('ForEach_Google_User_Target')['target']))" + }, + "Compose_Google_Target_Schema_Path": { + "runAfter": { + "Compose_Google_Target_Attribute_Path": [ "Succeeded" ] + }, + "type": "Compose", + "inputs": "@if(contains(outputs('Compose_Google_Target_Attribute_Path'), '['), concat(first(split(outputs('Compose_Google_Target_Attribute_Path'), '[')), last(split(outputs('Compose_Google_Target_Attribute_Path'), ']'))), outputs('Compose_Google_Target_Attribute_Path'))" + }, + "Filter_Google_Target_Schema": { + "runAfter": { + "Compose_Google_Target_Schema_Path": [ "Succeeded" ] + }, + "type": "Query", + "inputs": { + "from": "@body('Parse_Google_Required_User_Schemas')['Resources']", + "where": "@equals(toLower(item()['id']), if(startsWith(toLower(items('ForEach_Google_User_Target')['target']), 'urn:'), toLower(substring(items('ForEach_Google_User_Target')['target'], 0, lastIndexOf(items('ForEach_Google_User_Target')['target'], ':'))), 'urn:ietf:params:scim:schemas:core:2.0:user'))" + } + }, + "Filter_Google_Target_Attribute": { + "runAfter": { + "Filter_Google_Target_Schema": [ "Succeeded" ] + }, + "type": "Query", + "inputs": { + "from": "@coalesce(first(body('Filter_Google_Target_Schema'))?['attributes'], json('[]'))", + "where": "@equals(toLower(item()['name']), first(split(outputs('Compose_Google_Target_Schema_Path'), '.')))" + } + }, + "Filter_Google_Required_SubAttributes": { + "runAfter": { + "Filter_Google_Target_Attribute": [ "Succeeded" ] + }, + "type": "Query", + "inputs": { + "from": "@coalesce(first(body('Filter_Google_Target_Attribute'))?['subAttributes'], json('[]'))", + "where": "@and(equals(item()?['required'], true), or(not(contains(outputs('Compose_Google_Target_Schema_Path'), '.')), equals(toLower(item()['name']), last(split(outputs('Compose_Google_Target_Schema_Path'), '.')))))" + } + }, + "Filter_Google_Required_Descendant_Mappings": { + "runAfter": { + "Filter_Google_Required_SubAttributes": [ "Succeeded" ] + }, + "type": "Query", + "inputs": { + "from": "@variables('sourceToTargetLookup')", + "where": "@and(not(contains(outputs('Compose_Google_Target_Schema_Path'), '.')), equals(item()?['targetSchema']?['required'], true), equals(if(startsWith(toLower(item()['target']), 'urn:'), toLower(substring(item()['target'], 0, lastIndexOf(item()['target'], ':'))), 'urn:ietf:params:scim:schemas:core:2.0:user'), if(startsWith(toLower(items('ForEach_Google_User_Target')['target']), 'urn:'), toLower(substring(items('ForEach_Google_User_Target')['target'], 0, lastIndexOf(items('ForEach_Google_User_Target')['target'], ':'))), 'urn:ietf:params:scim:schemas:core:2.0:user')), or(startsWith(toLower(last(split(item()['target'], ':'))), concat(outputs('Compose_Google_Target_Schema_Path'), '.')), startsWith(toLower(last(split(item()['target'], ':'))), concat(outputs('Compose_Google_Target_Schema_Path'), '['))))" + } + }, + "Check_Google_Target_Required": { + "runAfter": { + "Filter_Google_Required_Descendant_Mappings": [ "Succeeded" ] + }, + "type": "If", + "expression": "@or(equals(items('ForEach_Google_User_Target')?['targetSchema']?['required'], true), not(empty(body('Filter_Google_Required_SubAttributes'))), not(empty(body('Filter_Google_Required_Descendant_Mappings'))), and(not(contains(outputs('Compose_Google_Target_Schema_Path'), '.')), equals(first(body('Filter_Google_Target_Attribute'))?['required'], true)))", + "actions": { + "Append_Google_Required_User_Target": { + "type": "AppendToArrayVariable", + "inputs": { + "name": "googleRequiredUserTargets", + "value": "@toLower(items('ForEach_Google_User_Target')['target'])" + } + }, + "Compose_Google_Required_Update_Source_Value": { + "runAfter": { + "Append_Google_Required_User_Target": [ "Succeeded" ] + }, + "type": "Compose", + "inputs": "@if(contains(items('ForEach_Google_User_Target')['source'], '.'), variables('googleUpdateUserBody')?[first(split(items('ForEach_Google_User_Target')['source'], '.'))]?[last(split(items('ForEach_Google_User_Target')['source'], '.'))], variables('googleUpdateUserBody')?[items('ForEach_Google_User_Target')['source']])" + }, + "Filter_Google_Nonempty_Update_Values": { + "runAfter": { + "Compose_Google_Required_Update_Source_Value": [ "Succeeded" ] + }, + "type": "Query", + "inputs": { + "from": "@if(contains(outputs('Compose_Dynamic_ArrayProperties'), items('ForEach_Google_User_Target')['source']), coalesce(outputs('Compose_Google_Required_Update_Source_Value'), json('[]')), createArray(outputs('Compose_Google_Required_Update_Source_Value')))", + "where": "@and(not(equals(item(), null)), not(equals(item(), '')))" + } + }, + "Compose_Google_Protected_Update_Body": { + "runAfter": { + "Filter_Google_Nonempty_Update_Values": [ "Succeeded" ] + }, + "type": "Compose", + "inputs": "@if(empty(body('Filter_Google_Nonempty_Update_Values')), if(contains(items('ForEach_Google_User_Target')['source'], '.'), if(contains(variables('googleUpdateUserBody'), first(split(items('ForEach_Google_User_Target')['source'], '.'))), setProperty(variables('googleUpdateUserBody'), first(split(items('ForEach_Google_User_Target')['source'], '.')), removeProperty(coalesce(variables('googleUpdateUserBody')?[first(split(items('ForEach_Google_User_Target')['source'], '.'))], json('{}')), last(split(items('ForEach_Google_User_Target')['source'], '.')))), variables('googleUpdateUserBody')), removeProperty(variables('googleUpdateUserBody'), items('ForEach_Google_User_Target')['source'])), if(contains(outputs('Compose_Dynamic_ArrayProperties'), items('ForEach_Google_User_Target')['source']), setProperty(variables('googleUpdateUserBody'), items('ForEach_Google_User_Target')['source'], body('Filter_Google_Nonempty_Update_Values')), variables('googleUpdateUserBody')))" + }, + "Set_Google_Protected_Update_Body": { + "runAfter": { + "Compose_Google_Protected_Update_Body": [ "Succeeded" ] + }, + "type": "SetVariable", + "inputs": { + "name": "googleUpdateUserBody", + "value": "@outputs('Compose_Google_Protected_Update_Body')" + } + }, + "Check_Google_Update_Source_Omitted": { + "runAfter": { + "Set_Google_Protected_Update_Body": [ "Succeeded" ] + }, + "type": "If", + "expression": "@empty(body('Filter_Google_Nonempty_Update_Values'))", + "actions": { + "Append_Google_Skipped_Update_Source": { + "type": "AppendToArrayVariable", + "inputs": { + "name": "googleSkippedUpdateSources", + "value": "@items('ForEach_Google_User_Target')['source']" + } + } + }, + "else": { "actions": {} } + } + }, + "else": { "actions": {} } + } + } + } + }, + "else": { "actions": {} } + }, + "Response_Initialization_Data": { + "runAfter": { + "Protect_Google_Required_User_Attributes": [ "Succeeded" ] + }, "type": "Response", "kind": "Http", "inputs": { @@ -1248,6 +1452,8 @@ "schema": "@body('Get_Schema')", "baseAddress": "@if(greater(length(coalesce(body('Filter_BaseAddress_Connectivity_Parameter'), json('[]'))), 0), first(body('Filter_BaseAddress_Connectivity_Parameter'))?['value'], '')", "sourceToTargetLookup": "@variables('sourceToTargetLookup')", + "googleRequiredUserTargets": "@variables('googleRequiredUserTargets')", + "googleSkippedUpdateSources": "@variables('googleSkippedUpdateSources')", "mappedUserTargetAttributes": "@body('Select_Target_Attribute_Names')", "mappedGroupTargetAttributes": "@body('Select_Target_Group_Attribute_Names')", "targetDirectoryUserAttributes": "@outputs('Compose_Target_User_Attributes')", @@ -1255,7 +1461,7 @@ "targetUserSchemaName": "@coalesce(first(body('Filter_Target_User_Object'))?['name'], 'urn:ietf:params:scim:schemas:core:2.0:User')", "targetGroupSchemaName": "@coalesce(first(body('Filter_Target_Group_Object'))?['name'], 'urn:ietf:params:scim:schemas:core:2.0:Group')", "userBody": "@outputs('Compose_Final_Dynamic_User_Body')", - "updateUserBody": "@outputs('Compose_Final_Dynamic_Update_User_Body')", + "updateUserBody": "@if(contains(toLower(coalesce(first(body('Filter_BaseAddress_Connectivity_Parameter'))?['value'], '')), 'googleapis.com'), variables('googleUpdateUserBody'), outputs('Compose_Final_Dynamic_Update_User_Body'))", "workflowRunId": "@workflow().run.name", "arrayProperties": "@outputs('Compose_Dynamic_ArrayProperties')", "isGroupSupported": "@outputs('Compose_IsGroupSupported')", diff --git a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/Orchestrator_Workflow.json b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/Orchestrator_Workflow.json index 6d89713d..3c6ae5f5 100644 --- a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/Orchestrator_Workflow.json +++ b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/Orchestrator_Workflow.json @@ -508,6 +508,8 @@ "overallLogicAppResult": "@if(or(not(equals(coalesce(body('Get_UserTests_Output_Content')?['CreateUserTestOutputs']?['overallResult'],'SKIPPED'),'PASSED')),not(equals(coalesce(body('Get_UserTests_Output_Content')?['UpdateUserTestOutputs']?['overallResult'],'SKIPPED'),'PASSED')),not(equals(coalesce(body('Get_UserTests_Output_Content')?['DisableUserTestOutputs']?['overallResult'],'SKIPPED'),'PASSED')),and(coalesce(body('Call_Initialization_Workflow')?['isManagerAttributeSupported'],false),not(equals(coalesce(body('Get_UserTests_Output_Content')?['ManagerTestOutputs']?['overallResult'],'SKIPPED'),'PASSED'))),not(equals(coalesce(body('Get_UserTests_Output_Content')?['RestoreUserTestOutputs']?['overallResult'],'SKIPPED'),'PASSED')),not(equals(coalesce(body('Get_SCIMTests_Output_Content')?['SCIMNullUpdateTestOutputs']?['overallResult'],'SKIPPED'),'PASSED')),and(coalesce(body('Call_Initialization_Workflow')?['isGroupSupported'],false),not(equals(coalesce(body('Get_GroupTests_Output_Content')?['CreateGroupTestOutputs']?['overallResult'],'SKIPPED'),'PASSED'))),and(coalesce(body('Call_Initialization_Workflow')?['isGroupSupported'],false),not(equals(coalesce(body('Get_GroupTests_Output_Content')?['UpdateGroupTestOutputs']?['overallResult'],'SKIPPED'),'PASSED'))),and(coalesce(body('Call_Initialization_Workflow')?['isGroupSupported'],false),not(equals(coalesce(body('Get_GroupTests_Output_Content')?['AddMemberTestOutputs']?['overallResult'],'SKIPPED'),'PASSED'))),and(coalesce(body('Call_Initialization_Workflow')?['isGroupSupported'],false),not(equals(coalesce(body('Get_GroupTests_Output_Content')?['RemoveMemberTestOutputs']?['overallResult'],'SKIPPED'),'PASSED'))),not(equals(coalesce(body('Get_SCIMTests_Output_Content')?['SchemaDiscoverabilityTestOutputs']?['overallResult'],'SKIPPED'),'PASSED')),not(equals(coalesce(body('Get_SCIMTests_Output_Content')?['SCIMUserCreateTestOutputs']?['overallResult'],'SKIPPED'),'PASSED')),and(coalesce(body('Call_Initialization_Workflow')?['isGroupSupported'],false),not(equals(coalesce(body('Get_SCIMTests_Output_Content')?['SCIMGroupCreateTestOutputs']?['overallResult'],'SKIPPED'),'PASSED'))),not(equals(coalesce(body('Get_SCIMTests_Output_Content')?['SCIMUserUpdateTestOutputs']?['overallResult'],'SKIPPED'),'PASSED')),and(coalesce(body('Call_Initialization_Workflow')?['isGroupSupported'],false),not(equals(coalesce(body('Get_SCIMTests_Output_Content')?['SCIMGroupUpdateTestOutputs']?['overallResult'],'SKIPPED'),'PASSED'))),not(equals(coalesce(body('Get_SCIMTests_Output_Content')?['SCIMUserPaginationTestOutputs']?['overallResult'],'SKIPPED'),'PASSED')),not(equals(coalesce(body('Get_UserTests_Output_Content')?['PODUserTestOutputs']?['overallResult'],'SKIPPED'),'PASSED')),and(coalesce(body('Call_Initialization_Workflow')?['isGroupSupported'],false),not(equals(coalesce(body('Get_GroupTests_Output_Content')?['PODGroupTestOutputs']?['overallResult'],'SKIPPED'),'PASSED'))),equals(actions('Call_Initialization_Workflow')?['status'],'Failed'),equals(coalesce(body('Poll_UserTests_Status')?['properties']?['status'],''),'Failed'),equals(coalesce(body('Poll_GroupTests_Status')?['properties']?['status'],''),'Failed'),or(not(equals(coalesce(body('Get_SCIMTests_Output_Content')?['authenticationModelResult'],'FAILED'),'PASSED')),and(coalesce(body('Call_Initialization_Workflow')?['isManagerAttributeSupported'],false),contains(string(coalesce(body('Call_Initialization_Workflow')?['targetDirectoryUserAttributes'],json('[]'))),'manager'),not(equals(coalesce(body('Get_SCIMTests_Output_Content')?['SCIMUpdateManagerTestOutputs']?['overallResult'],'SKIPPED'),'PASSED'))))),'Failed',if(equals(coalesce(body('Poll_SCIMTests_Status')?['properties']?['status'],''),'Failed'),'Failed','Succeeded'))", "optionalSuccessTestsResult": "@if(or(not(equals(coalesce(body('Get_UserTests_Output_Content')?['DeleteUserTestOutputs']?['overallResult'], 'SKIPPED'), 'PASSED')), and(coalesce(body('Call_Initialization_Workflow')?['isGroupSupported'], false), not(equals(coalesce(body('Get_GroupTests_Output_Content')?['DeleteGroupTestOutputs']?['overallResult'], 'SKIPPED'), 'PASSED'))), and(coalesce(body('Call_Initialization_Workflow')?['isGroupSupported'], false), not(equals(coalesce(body('Get_GroupTests_Output_Content')?['RestoreGroupTestOutputs']?['overallResult'], 'SKIPPED'), 'PASSED'))), and(coalesce(body('Call_Initialization_Workflow')?['isGroupSupported'], false), not(equals(coalesce(body('Get_SCIMTests_Output_Content')?['SCIMGroupPaginationTestOutputs']?['overallResult'], 'SKIPPED'), 'PASSED')))), 'WARNING', 'PASSED')", "authenticationModelResult": "@coalesce(body('Get_SCIMTests_Output_Content')?['authenticationModelResult'], 'FAILED')", + "authenticationModelBypassed": "@coalesce(body('Get_SCIMTests_Output_Content')?['authenticationModelBypassed'], false)", + "authenticationModelBypassReason": "@body('Get_SCIMTests_Output_Content')?['authenticationModelBypassReason']", "validationCompletedAtUtc": "@utcNow()" } }, @@ -753,6 +755,8 @@ "SCIMTests_Workflow": "@outputs('Compose_SCIMTests_RunLink')" }, "authenticationModelResult": "@outputs('Evaluate_Test_Results')?['authenticationModelResult']", + "authenticationModelBypassed": "@outputs('Evaluate_Test_Results')?['authenticationModelBypassed']", + "authenticationModelBypassReason": "@outputs('Evaluate_Test_Results')?['authenticationModelBypassReason']", "overallResult": "@outputs('Evaluate_Test_Results')?['overallLogicAppResult']", "selectedAuthenticationMethod": "@parameters('selectedAuthenticationMethod')", "LogicAppVersion": "@parameters('LogicAppVersion')", diff --git a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/SCIM-Validation-Test-Overview.md b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/SCIM-Validation-Test-Overview.md index 3f23e7a5..99523212 100644 --- a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/SCIM-Validation-Test-Overview.md +++ b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/SCIM-Validation-Test-Overview.md @@ -67,8 +67,8 @@ Tests that don't apply are reported as **SKIPPED** (not failures). However, grou | 18 | **SCIM_User_Update_Test** | mandatory | Direct `PATCH /Users/{id}` with auto-generated update values for eligible attributes (single-valued, string/integer/datetime/boolean, excluding groups/roles/id/schemas/meta/active). | PATCH returns 200/204; subsequent GET returns 200/204; all PATCHed attribute values match in the response; zero mismatches. Passes with `(no updatable attributes)` if no eligible attributes exist. | | 19 | **SCIM_Group_Create_Test** | mandatory | Direct `POST /Groups` with SCIM group body. **FAILED** if groups not supported. | HTTP 200 or 201; response contains non-empty `id`; returned `displayName` matches sent `displayName`. | | 20 | **SCIM_Group_Update_Test** | mandatory | Direct `PATCH /Groups/{id}` with attribute updates (single-valued, non-reference, excluding id/members/schemas/meta). | PATCH returns 200/204; GET returns 200/204; all PATCHed attributes match; zero mismatches. | -| 21 | **Validate_Credentials_Test** | mandatory | OAuth 2.0 Client Credentials flow — acquires a token from the ISV's `scimTokenEndpoint`, sends credentials in the required `Header` or `Body` location, includes the optional space-delimited scope string, then validates SCIM access with the token. **Skipped** when `scimTokenEndpoint` is empty (static bearer token). | Token endpoint returns 2xx with `access_token`; token validity between 60–360 minutes; SCIM endpoint returns 2xx when called with the token. | -| 22 | **Federated_Identity_Test** | mandatory | Workload identity federation — acquires Entra ID token, then exchanges it for ISV token via one of three flows: Google Service Account, Google STS, or generic federated endpoint. **Skipped** when federated parameters are empty. | Entra token acquisition returns 2xx with `access_token`; federated token exchange returns 2xx with valid token. Supports Google SA flow (STS exchange → SA impersonation), Google STS flow (direct exchange), and generic flow (client assertion). | +| 21 | **Validate_Credentials_Test** | mandatory when selected | OAuth 2.0 Client Credentials flow — acquires a token from the ISV's `scimTokenEndpoint`, sends credentials in the required `Header` or `Body` location, includes the optional space-delimited scope string, then validates SCIM access with the token. **Skipped** when this method is not selected. | Token endpoint returns 2xx with `access_token`; token validity between 60–360 minutes; SCIM endpoint returns 2xx when called with the token. | +| 22 | **Federated_Identity_Test** | mandatory when selected, except Google below | Workload identity federation — acquires Entra ID token, then exchanges it for ISV token via one of three flows: Google Service Account, Google STS, or generic federated endpoint. **Skipped** when unselected or covered by the temporary Google federation exception below. | Entra token acquisition returns 2xx with `access_token`; federated token exchange returns 2xx with valid token. Supports Google SA flow (STS exchange → SA impersonation), Google STS flow (direct exchange), and generic flow (client assertion). For the Google exception, diagnostics stay SKIPPED and only the aggregate authentication result is marked PASSED. | | 23 | **SCIM_User_Pagination_Test** | mandatory | Ensures ≥11 users exist (creates throwaway users if needed), then paginates `GET /Users?startIndex=N&count=5` across pages. Cleans up created users afterward. | At least 2 pages traversed; all pages return HTTP 200; response `startIndex` matches expected value (`page * 5 + 1`); zero failed page verifications. | | 24 | **SCIM_Group_Pagination_Test** | optional | Same as user pagination but for `/Groups`. Failure = WARNING. **Skipped** if groups not supported. | At least 2 pages traversed; all pages return HTTP 200; response `startIndex` matches expected; zero failed verifications. | | 25 | **SCIM_Update_Manager_Test** | mandatory | Direct SCIM PATCH test for manager attribute: set (Add), change (Replace), remove (Replace with empty). **Skipped** if `manager` not in target directory attributes. | Each PATCH returns 200/204; GET after each operation confirms manager value matches expected; all three operations (set/change/remove) succeed. | @@ -77,6 +77,14 @@ Tests that don't apply are reported as **SKIPPED** (not failures). However, grou ## Test Categories Summary +The existing post-create and post-PATCH propagation waits in `SCIMTests_Workflow.json` are 30 seconds before dependent operations or verification reads. These waits cover null updates, user and group updates, and manager updates to allow asynchronously updated attributes to become visible. + +For connections whose saved `baseAddress` contains `googleapis.com` (case-insensitive), initialization reads `/Schemas` and identifies mapped required attributes and subattributes, also honoring `targetSchema.required`. `SCIM_Null_Update_Test` excludes those targets from both empty-string and null PATCH payloads and their verification lists. For example, a required `emails[type eq "other"].value` is not cleared; optional sibling fields and optional address or enterprise-extension fields remain eligible. Clearing a whole complex attribute is also excluded when it contains a subattribute declared required by either `/Schemas` or another mapping's `targetSchema.required`, even if the parent is optional. The same parent protection applies to blank mapped sources in the Entra update payload. Schema URNs are matched separately, so a required extension attribute does not exclude an optional core attribute with the same name. + +The Google Entra update payload is protected separately: blank/null source values (including blank array entries) mapped to required targets are omitted, while populated updates, `false`, and `0` are retained. Omitted sources are listed in initialization's `googleSkippedUpdateSources` and excluded from update-value comparisons. For Google connections only, the Entra update step rejects an entirely empty update body before calling Graph, reporting a validation failure rather than sending an empty PATCH. Other providers retain their previous update-request behavior, including sending an empty body if one is produced. This changes the validation workflow's Graph PATCH, not the provisioning service's saved mappings or future production behavior. Manager-removal lifecycle phases are unchanged. + +If the Google null test has no eligible targets, neither clearing PATCH is sent. Its summary explicitly reports `clearingPhasesSkipped`, the reason, excluded required targets, and zero verified attributes. Successful setup can still pass; HTTP or verification failures are not exempted. Other providers retain their existing attribute-selection behavior. + | Category | Tests | Exercises | |----------|-------|-----------| | **User Lifecycle** | 7 | Full CRUD + Manager + Restore + POD via Entra provisioning engine | @@ -105,8 +113,8 @@ not affect pass/fail. Runs that never reach the final action have no final JSON. The Orchestrator's `Evaluate_Test_Results` action computes `overallLogicAppResult` as follows: -- **`Succeeded`** — Existing mandatory/capability gates pass, AND the selected authentication test returned `PASSED`. -- **`Failed`** — A required semantic gate does not pass, OR the selected authentication test is failed, skipped, missing, or the selection is invalid. +- **`Succeeded`** — Existing mandatory/capability gates pass, AND the authentication model result is `PASSED` (selected diagnostic success or the temporary Google federation exception below). +- **`Failed`** — A required semantic gate does not pass, OR the selected authentication test is failed, skipped, or missing outside the Google exception, or the selection is invalid. ### Authentication Model (one selected method) @@ -115,10 +123,18 @@ Set `selectedAuthenticationMethod` to one of the following values. Existing toke | Selection | Required test | Authentication result | |-----------|---------------|-----------------------| | `OAuth2ClientCredentials` | `Validate_Credentials_Test` | PASSED only when this test passes | -| `FederatedIdentity` | `Federated_Identity_Test` | PASSED only when this test passes | +| `FederatedIdentity` | `Federated_Identity_Test` | PASSED when this test passes, or by the temporary Google exception below | | Empty/invalid | Neither | FAILED | -Final results identify the selection. Success of the other method cannot rescue a failed or skipped selected test. Existing non-authentication requirements remain unchanged. +Final results identify the selection. Outside the temporary Google exception, success of the other method cannot rescue a failed or skipped selected test. Existing non-authentication requirements remain unchanged. + +#### Temporary Google workload identity exception + +When the saved connection's `baseAddress` contains `googleapis.com` (case-insensitive) **and** `selectedAuthenticationMethod` is `FederatedIdentity`, both standalone authentication diagnostics remain `SKIPPED` and make no token requests. The aggregate `authenticationModelResult` is temporarily marked `PASSED` by exception. Child and final orchestrator results expose `authenticationModelBypassed: true` and `authenticationModelBypassReason` so this is distinguishable from a successful diagnostic. + +The standalone diagnostic acquires a tenant-app token; it cannot represent Microsoft's first-party SyncFabric client identity when Google restricts the token's authorized party (`azp`). This exception does not relax that provider restriction or change the real connection. Existing mandatory Entra provisioning, provisioning-log, and SCIM checks must still pass for overall success. A passing aggregate authentication result alone does not establish E2E success or prove a fresh STS token exchange occurred. + +Google connections selecting `OAuth2ClientCredentials`, non-Google connections, missing base addresses, and invalid method selections do not receive this exception. Standalone tests have not been made globally optional. `FederatedIdentity` is the selector name for Workload Identity Federation (WIF), matching the existing test/output naming. Selection does not reroute the other tests' credentials: direct semantic requests still use `scimBearerToken`; provisioning requests use the separately saved Entra connection. Tokens acquired inside either authentication test remain local to that test. diff --git a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/SCIMTests_Workflow.json b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/SCIMTests_Workflow.json index 39b3b5c7..fac16395 100644 --- a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/SCIMTests_Workflow.json +++ b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/SCIMTests_Workflow.json @@ -357,6 +357,13 @@ ] } }, + "Compose_Google_Federated_Auth_Bypass": { + "runAfter": { + "Response_Immediate": [ "Succeeded" ] + }, + "type": "Compose", + "inputs": "@and(equals(parameters('selectedAuthenticationMethod'), 'FederatedIdentity'), contains(toLower(coalesce(triggerBody()?['initializationData']?['baseAddress'], '')), 'googleapis.com'))" + }, "SCIMTests_Scope": { "actions": { "Schema_Discoverability_Test": { @@ -1599,7 +1606,7 @@ "type": "Wait", "inputs": { "interval": { - "count": 7, + "count": 30, "unit": "Second" } } @@ -1699,7 +1706,7 @@ "type": "Query", "inputs": { "from": "@coalesce(triggerBody()?['initializationData']?['sourceToTargetLookup'], json('[]'))", - "where": "@and(equals(coalesce(item()?['matchingPrecedence'], 0), 0), not(contains(toLower(coalesce(item()?['targetSchema']?['type'], '')), 'reference')), not(equals(coalesce(item()?['target'], ''), 'userName')), not(equals(coalesce(item()?['target'], ''), 'active')), not(equals(coalesce(item()?['target'], ''), 'externalId')), not(equals(coalesce(item()?['target'], ''), 'roles')), if(startsWith(coalesce(item()?['target'], ''), 'urn:'), not(contains(string(body('Query_Required_Enterprise_Attrs')), concat('\"name\":\"', last(split(item()?['target'], ':')), '\"'))), not(contains(string(body('Query_Required_Core_Attrs')), concat('\"name\":\"', if(contains(coalesce(item()?['target'], ''), '['), first(split(item()?['target'], '[')), if(contains(coalesce(item()?['target'], ''), '.'), first(split(item()?['target'], '.')), coalesce(item()?['target'], ''))), '\"')))))" + "where": "@and(equals(coalesce(item()?['matchingPrecedence'], 0), 0), not(contains(toLower(coalesce(item()?['targetSchema']?['type'], '')), 'reference')), not(equals(coalesce(item()?['target'], ''), 'userName')), not(equals(coalesce(item()?['target'], ''), 'active')), not(equals(coalesce(item()?['target'], ''), 'externalId')), not(equals(coalesce(item()?['target'], ''), 'roles')), if(contains(toLower(coalesce(triggerBody()?['initializationData']?['baseAddress'], '')), 'googleapis.com'), and(not(equals(coalesce(item()?['targetSchema']?['required'], false), true)), not(contains(coalesce(triggerBody()?['initializationData']?['googleRequiredUserTargets'], json('[]')), toLower(coalesce(item()?['target'], ''))))), if(startsWith(coalesce(item()?['target'], ''), 'urn:'), not(contains(string(body('Query_Required_Enterprise_Attrs')), concat('\"name\":\"', last(split(item()?['target'], ':')), '\"'))), not(contains(string(body('Query_Required_Core_Attrs')), concat('\"name\":\"', if(contains(coalesce(item()?['target'], ''), '['), first(split(item()?['target'], '[')), if(contains(coalesce(item()?['target'], ''), '.'), first(split(item()?['target'], '.')), coalesce(item()?['target'], ''))), '\"'))))))" } }, "ForEach_Build_Null_Patch_Operations": { @@ -1803,34 +1810,41 @@ "Operations": "@if(coalesce(triggerBody()?['initializationData']?['isVerbosePatchSupported'], true), variables('scimNullPatchOperations'), union(body('Filter_Null_MultiValued_Ops'), if(greater(length(body('Filter_Null_Scalar_Attrs')), 0), json(concat('[{\"op\":\"replace\",\"value\":{', join(body('Select_Null_Batch_KeyValues'), ','), '}}]')), json('[]'))))" } }, - "SCIM_Patch_Null_Update": { + "Check_Null_Update_Has_Targets": { "runAfter": { "Compose_SCIM_Null_Patch_Body": [ "Succeeded" ] }, - "type": "Http", - "inputs": { - "uri": "@{concat(parameters('scimEndpoint'), '/Users/', outputs('Compose_SCIM_Null_User_Id'))}", - "method": "PATCH", - "headers": { - "Authorization": "@{concat('Bearer ', parameters('scimBearerToken'))}", - "Content-Type": "@{parameters('scimContentType')}", - "Accept": "@{parameters('scimContentType')}" - }, - "body": "@outputs('Compose_SCIM_Null_Patch_Body')" - } + "type": "If", + "expression": "@or(not(contains(toLower(coalesce(triggerBody()?['initializationData']?['baseAddress'], '')), 'googleapis.com')), not(empty(body('Filter_Null_Update_Target_Attributes'))))", + "actions": { + "SCIM_Patch_Null_Update": { + "type": "Http", + "inputs": { + "uri": "@{concat(parameters('scimEndpoint'), '/Users/', outputs('Compose_SCIM_Null_User_Id'))}", + "method": "PATCH", + "headers": { + "Authorization": "@{concat('Bearer ', parameters('scimBearerToken'))}", + "Content-Type": "@{parameters('scimContentType')}", + "Accept": "@{parameters('scimContentType')}" + }, + "body": "@outputs('Compose_SCIM_Null_Patch_Body')" + } + } + }, + "else": { "actions": {} } }, "Delay_After_SCIM_Null_Update": { "runAfter": { - "SCIM_Patch_Null_Update": [ + "Check_Null_Update_Has_Targets": [ "Succeeded" ] }, "type": "Wait", "inputs": { "interval": { - "count": 7, + "count": 30, "unit": "Second" } } @@ -2006,34 +2020,41 @@ "Operations": "@if(coalesce(triggerBody()?['initializationData']?['isVerbosePatchSupported'], true), body('Select_Null_Patch_Ops_Phase2'), union(body('Filter_Null_Phase2_MultiValued_Ops'), if(greater(length(body('Filter_Null_Phase2_Batch_Ops')), 0), json(concat('[{\"op\":\"replace\",\"value\":{', join(body('Select_Null_Phase2_Batch_KV'), ','), '}}]')), json('[]'))))" } }, - "SCIM_Patch_Null_Update_Phase2": { + "Check_Null_Update_Phase2_Has_Targets": { "runAfter": { "Compose_Null_Patch_Request_Phase2": [ "Succeeded" ] }, - "type": "Http", - "inputs": { - "uri": "@{concat(parameters('scimEndpoint'), '/Users/', outputs('Compose_SCIM_Null_User_Id'))}", - "method": "PATCH", - "headers": { - "Authorization": "@{concat('Bearer ', parameters('scimBearerToken'))}", - "Content-Type": "@{parameters('scimContentType')}", - "Accept": "@{parameters('scimContentType')}" - }, - "body": "@outputs('Compose_Null_Patch_Request_Phase2')" - } + "type": "If", + "expression": "@or(not(contains(toLower(coalesce(triggerBody()?['initializationData']?['baseAddress'], '')), 'googleapis.com')), not(empty(body('Filter_Null_Update_Target_Attributes'))))", + "actions": { + "SCIM_Patch_Null_Update_Phase2": { + "type": "Http", + "inputs": { + "uri": "@{concat(parameters('scimEndpoint'), '/Users/', outputs('Compose_SCIM_Null_User_Id'))}", + "method": "PATCH", + "headers": { + "Authorization": "@{concat('Bearer ', parameters('scimBearerToken'))}", + "Content-Type": "@{parameters('scimContentType')}", + "Accept": "@{parameters('scimContentType')}" + }, + "body": "@outputs('Compose_Null_Patch_Request_Phase2')" + } + } + }, + "else": { "actions": {} } }, "Delay_After_SCIM_Null_Update_Phase2": { "runAfter": { - "SCIM_Patch_Null_Update_Phase2": [ + "Check_Null_Update_Phase2_Has_Targets": [ "Succeeded" ] }, "type": "Wait", "inputs": { "interval": { - "count": 7, + "count": 30, "unit": "Second" } } @@ -2119,15 +2140,18 @@ "inputs": { "testName": "SCIM_Null_Update_Test", "timestamp": "@utcNow()", - "phase1_emptyString_patchStatusCode": "@outputs('SCIM_Patch_Null_Update')['statusCode']", + "clearingPhasesSkipped": "@and(contains(toLower(coalesce(triggerBody()?['initializationData']?['baseAddress'], '')), 'googleapis.com'), empty(body('Filter_Null_Update_Target_Attributes')))", + "skipReason": "@if(and(contains(toLower(coalesce(triggerBody()?['initializationData']?['baseAddress'], '')), 'googleapis.com'), empty(body('Filter_Null_Update_Target_Attributes'))), 'No eligible optional attributes; empty-string and null PATCH requests were not sent.', null)", + "excludedGoogleRequiredTargets": "@coalesce(triggerBody()?['initializationData']?['googleRequiredUserTargets'], json('[]'))", + "phase1_emptyString_patchStatusCode": "@outputs('SCIM_Patch_Null_Update')?['statusCode']", "phase1_emptyString_failedVerifications": "@length(body('Filter_Failed_Null_Verifications'))", - "phase2_null_patchStatusCode": "@outputs('SCIM_Patch_Null_Update_Phase2')['statusCode']", + "phase2_null_patchStatusCode": "@outputs('SCIM_Patch_Null_Update_Phase2')?['statusCode']", "phase2_null_failedVerifications": "@length(body('Filter_Failed_Null_Phase2_Verifications'))", "phase2_null_verificationDetails": "@body('Filter_Failed_Null_Phase2_Verifications')", "totalAttributesVerified": "@length(variables('scimNullUpdateResults'))", "passedCount": "@length(body('Filter_Passed_Null_Verifications'))", "failedCount": "@length(body('Filter_Failed_Null_Verifications'))", - "overallResult": "@if(and(not(empty(outputs('Compose_SCIM_Null_User_Id'))), or(equals(outputs('SCIM_Patch_Null_Update')['statusCode'], 200), equals(outputs('SCIM_Patch_Null_Update')['statusCode'], 204)), equals(length(body('Filter_Failed_Null_Verifications')), 0), or(equals(outputs('SCIM_Patch_Null_Update_Phase2')['statusCode'], 200), equals(outputs('SCIM_Patch_Null_Update_Phase2')['statusCode'], 204)), equals(length(body('Filter_Failed_Null_Phase2_Verifications')), 0)), 'PASSED', 'FAILED')" + "overallResult": "@if(and(not(empty(outputs('Compose_SCIM_Null_User_Id'))), or(and(contains(toLower(coalesce(triggerBody()?['initializationData']?['baseAddress'], '')), 'googleapis.com'), empty(body('Filter_Null_Update_Target_Attributes'))), equals(outputs('SCIM_Patch_Null_Update')?['statusCode'], 200), equals(outputs('SCIM_Patch_Null_Update')?['statusCode'], 204)), equals(length(body('Filter_Failed_Null_Verifications')), 0), or(and(contains(toLower(coalesce(triggerBody()?['initializationData']?['baseAddress'], '')), 'googleapis.com'), empty(body('Filter_Null_Update_Target_Attributes'))), equals(outputs('SCIM_Patch_Null_Update_Phase2')?['statusCode'], 200), equals(outputs('SCIM_Patch_Null_Update_Phase2')?['statusCode'], 204)), equals(length(body('Filter_Failed_Null_Phase2_Verifications')), 0)), 'PASSED', 'FAILED')" } }, "Filter_MultiValue_Targets": { @@ -2784,7 +2808,7 @@ "type": "Wait", "inputs": { "interval": { - "count": 7, + "count": 30, "unit": "Second" } } @@ -2946,7 +2970,7 @@ "type": "Wait", "inputs": { "interval": { - "count": 7, + "count": 30, "unit": "Second" } } @@ -3184,7 +3208,7 @@ "type": "Wait", "inputs": { "interval": { - "count": 7, + "count": 30, "unit": "Second" } } @@ -3366,7 +3390,7 @@ "type": "Wait", "inputs": { "interval": { - "count": 7, + "count": 30, "unit": "Second" } } @@ -4154,6 +4178,14 @@ "FederatedIdentity" ] }, + { + "not": { + "equals": [ + "@outputs('Compose_Google_Federated_Auth_Bypass')", + true + ] + } + }, { "or": [ { "equals": [ "@triggerBody()?['EnabledTests']", "All" ] }, @@ -4856,7 +4888,7 @@ "type": "Wait", "inputs": { "interval": { - "count": 7, + "count": 30, "unit": "Second" } } @@ -4896,7 +4928,7 @@ "type": "Wait", "inputs": { "interval": { - "count": 7, + "count": 30, "unit": "Second" } } @@ -4952,7 +4984,7 @@ "type": "Wait", "inputs": { "interval": { - "count": 7, + "count": 30, "unit": "Second" } } @@ -4992,7 +5024,7 @@ "type": "Wait", "inputs": { "interval": { - "count": 7, + "count": 30, "unit": "Second" } } @@ -5051,7 +5083,7 @@ "type": "Wait", "inputs": { "interval": { - "count": 7, + "count": 30, "unit": "Second" } } @@ -5274,17 +5306,24 @@ } }, "runAfter": { - "Response_Immediate": [ + "Compose_Google_Federated_Auth_Bypass": [ "Succeeded" ] }, "type": "Scope" }, - "Compose_Final_Results": { + "Compose_Authentication_Model_Result": { "runAfter": { "SCIMTests_Scope": [ "Succeeded", "Failed", "Skipped", "TimedOut" ] }, "type": "Compose", + "inputs": "@if(equals(outputs('Compose_Google_Federated_Auth_Bypass'), true), 'PASSED', if(equals(parameters('selectedAuthenticationMethod'), 'OAuth2ClientCredentials'), coalesce(variables('ValidateCredentialsTestOutputs')?['overallResult'], 'FAILED'), if(equals(parameters('selectedAuthenticationMethod'), 'FederatedIdentity'), coalesce(variables('FederatedIdentityTestOutputs')?['overallResult'], 'FAILED'), 'FAILED')))" + }, + "Compose_Final_Results": { + "runAfter": { + "Compose_Authentication_Model_Result": [ "Succeeded" ] + }, + "type": "Compose", "inputs": { "SchemaDiscoverabilityTestOutputs": "@variables('SchemaDiscoverabilityTestOutputs')", "SCIMNullUpdateTestOutputs": "@variables('SCIMNullUpdateTestOutputs')", @@ -5298,9 +5337,11 @@ "SCIMGroupPaginationTestOutputs": "@variables('SCIMGroupPaginationTestOutputs')", "SCIMUpdateManagerTestOutputs": "@variables('SCIMUpdateManagerTestOutputs')", "testSuiteName": "SCIMTests", - "status": "@if(or(or(or(equals(variables('SchemaDiscoverabilityTestOutputs')['overallResult'], 'FAILED'), equals(variables('SCIMNullUpdateTestOutputs')['overallResult'], 'FAILED')), or(not(equals(if(equals(parameters('selectedAuthenticationMethod'), 'OAuth2ClientCredentials'), coalesce(variables('ValidateCredentialsTestOutputs')?['overallResult'], 'FAILED'), if(equals(parameters('selectedAuthenticationMethod'), 'FederatedIdentity'), coalesce(variables('FederatedIdentityTestOutputs')?['overallResult'], 'FAILED'), 'FAILED')), 'PASSED')), equals(variables('SCIMUserCreateTestOutputs')['overallResult'], 'FAILED'))), or(or(or(equals(variables('SCIMGroupCreateTestOutputs')['overallResult'], 'FAILED'), equals(variables('SCIMUserUpdateTestOutputs')['overallResult'], 'FAILED')), equals(variables('SCIMGroupUpdateTestOutputs')['overallResult'], 'FAILED')), and(contains(string(coalesce(triggerBody()?['initializationData']?['targetDirectoryUserAttributes'], json('[]'))), 'manager'), equals(variables('SCIMUpdateManagerTestOutputs')['overallResult'], 'FAILED')))), 'Failed', 'Passed')", + "status": "@if(or(or(or(equals(variables('SchemaDiscoverabilityTestOutputs')['overallResult'], 'FAILED'), equals(variables('SCIMNullUpdateTestOutputs')['overallResult'], 'FAILED')), or(not(equals(outputs('Compose_Authentication_Model_Result'), 'PASSED')), equals(variables('SCIMUserCreateTestOutputs')['overallResult'], 'FAILED'))), or(or(or(equals(variables('SCIMGroupCreateTestOutputs')['overallResult'], 'FAILED'), equals(variables('SCIMUserUpdateTestOutputs')['overallResult'], 'FAILED')), equals(variables('SCIMGroupUpdateTestOutputs')['overallResult'], 'FAILED')), and(contains(string(coalesce(triggerBody()?['initializationData']?['targetDirectoryUserAttributes'], json('[]'))), 'manager'), equals(variables('SCIMUpdateManagerTestOutputs')['overallResult'], 'FAILED')))), 'Failed', 'Passed')", "workflowRunId": "@workflow().run.name", - "authenticationModelResult": "@if(equals(parameters('selectedAuthenticationMethod'), 'OAuth2ClientCredentials'), coalesce(variables('ValidateCredentialsTestOutputs')?['overallResult'], 'FAILED'), if(equals(parameters('selectedAuthenticationMethod'), 'FederatedIdentity'), coalesce(variables('FederatedIdentityTestOutputs')?['overallResult'], 'FAILED'), 'FAILED'))", + "authenticationModelResult": "@outputs('Compose_Authentication_Model_Result')", + "authenticationModelBypassed": "@outputs('Compose_Google_Federated_Auth_Bypass')", + "authenticationModelBypassReason": "@if(equals(outputs('Compose_Google_Federated_Auth_Bypass'), true), 'Temporary Google workload identity exception: standalone authentication diagnostics skipped because the tenant-app token does not represent the Microsoft SyncFabric client identity. Authentication is marked PASSED by exception; mandatory Entra provisioning and SCIM checks remain required.', null)", "selectedAuthenticationMethod": "@parameters('selectedAuthenticationMethod')" } } diff --git a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/UserTests_Workflow.json b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/UserTests_Workflow.json index 7f2d667b..06c3d92c 100644 --- a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/UserTests_Workflow.json +++ b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/UserTests_Workflow.json @@ -631,13 +631,37 @@ } } }, - "Update_User_Update_User_Attributes": { + "Validate_User_Update_Attributes": { "runAfter": { "Update_User_Query_User_By_Id": [ "Succeeded", "Failed" ] }, + "type": "If", + "expression": "@contains(toLower(coalesce(triggerBody()?['initializationData']?['baseAddress'], '')), 'googleapis.com')", + "actions": { + "Validate_Google_User_Update_Attributes": { + "type": "ParseJson", + "inputs": { + "content": "@triggerBody()?['initializationData']?['updateUserBody']", + "schema": { + "type": "object", + "minProperties": 1 + } + } + } + }, + "else": { + "actions": {} + } + }, + "Update_User_Update_User_Attributes": { + "runAfter": { + "Validate_User_Update_Attributes": [ + "Succeeded" + ] + }, "type": "Http", "inputs": { "uri": "https://graph.microsoft.com/v1.0/users/@{body('Update_User_-_Create_User_Graph')?['id']}", @@ -721,6 +745,14 @@ "@items('ForEach_SourceToTargetLookup')?['source']" ] } + }, + { + "not": { + "contains": [ + "@coalesce(triggerBody()?['initializationData']?['googleSkippedUpdateSources'], json('[]'))", + "@items('ForEach_SourceToTargetLookup')?['source']" + ] + } } ] }, diff --git a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/VERSION b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/VERSION index ed9d00f9..fe6b09a7 100644 --- a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/VERSION +++ b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/VERSION @@ -1 +1 @@ -14.0 +15.0