From b61a85832b44e9010dec0d8dc064d4d203b19724 Mon Sep 17 00:00:00 2001 From: Darrick Joo Date: Fri, 24 Jul 2026 14:36:17 +0200 Subject: [PATCH 1/3] Fix internal admin losing SUPER during customized plan provisioning Internal tenant administrators (Global Admin / D365 Admin) lost SUPER when provisioned for the first time with a customized license plan that omits SUPER. In codeunit 9018 "Azure AD Plan Impl.", AddNewlyAssignedUserPlans only applied the admin exemption on the default (non-customized) plan branch; the customized branch set ShouldRemoveSuper solely from whether the configuration contained SUPER, bypassing the admin check. Add a local IsUserInternalAdmin helper (Global Admin, D365 Admin, BC Admin plans only, excluding delegated partner roles) and guard the customized branch with it, so internal admins keep SUPER even when the custom mapping omits it. Ordinary users still lose SUPER, and delegated admins are intentionally not exempted here since their SUPER is governed separately in AssignPlanToUserWithDelegatedRole. Add tests covering internal admin retain, D365 admin retain, non-admin remove, customized-with-SUPER retain, delegated admin remove, and the existing-user path. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/AzureADPlanModuleTest.codeunit.al | 233 ++++++++++++++++++ .../src/AzureADPlanImpl.Codeunit.al | 22 +- 2 files changed, 254 insertions(+), 1 deletion(-) diff --git a/src/Apps/W1/PlanConfiguration/test/src/AzureADPlanModuleTest.codeunit.al b/src/Apps/W1/PlanConfiguration/test/src/AzureADPlanModuleTest.codeunit.al index 98c2d078b4d..dbe30a85532 100644 --- a/src/Apps/W1/PlanConfiguration/test/src/AzureADPlanModuleTest.codeunit.al +++ b/src/Apps/W1/PlanConfiguration/test/src/AzureADPlanModuleTest.codeunit.al @@ -830,6 +830,225 @@ codeunit 139509 "Azure AD Plan Module Test" TearDown(); end; + [Test] + [TransactionModel(TransactionModel::AutoRollback)] + [CommitBehavior(CommitBehavior::Ignore)] + [Scope('OnPrem')] + procedure TestInternalAdminKeepsSuperWhenCustomizedPlanOmitsSuper() + var + User: Record User; + AzureADPlan: Codeunit "Azure AD Plan"; + PlanConfigurationLibrary: Codeunit "Plan Configuration Library"; + PlanIds: Codeunit "Plan Ids"; + begin + // [SCENARIO] An internal tenant admin (Global Admin) keeps SUPER on first login even when the assigned + // license plan configuration is customized and the customization omits SUPER. + Initialize(); + LibraryLowerPermissions.SetOutsideO365Scope(); + LibraryLowerPermissions.AddSecurity(); + + // [GIVEN] A first-login super user that is provisioned with a Premium plan and the internal Global Admin plan + CreateUserWithPlan(User, PlanIds.GetPremiumPlanId()); + AddSubscriptionPlanToUser(User, PlanIds.GetGlobalAdminPlanId()); + + // [GIVEN] The Premium plan configuration is customized and does not contain SUPER + PlanConfigurationLibrary.AddConfiguration(PlanIds.GetPremiumPlanId(), true); + + // [WHEN] RefreshUserPlanAssignments is invoked (first login flow) + LibraryLowerPermissions.SetO365BusFull(); + LibraryLowerPermissions.AddSecurity(); + AzureADPlan.RefreshUserPlanAssignments(User."User Security ID"); + + // [THEN] The internal admin still has SUPER + Assert.IsTrue(IsUserInPermissionSet(User."User Security ID", 'SUPER'), 'Internal tenant admin should retain SUPER when the customized plan omits SUPER.'); + + // Rollback SaaS test + TearDown(); + end; + + [Test] + [TransactionModel(TransactionModel::AutoRollback)] + [CommitBehavior(CommitBehavior::Ignore)] + [Scope('OnPrem')] + procedure TestD365AdminKeepsSuperWhenCustomizedPlanOmitsSuper() + var + User: Record User; + AzureADPlan: Codeunit "Azure AD Plan"; + PlanConfigurationLibrary: Codeunit "Plan Configuration Library"; + PlanIds: Codeunit "Plan Ids"; + begin + // [SCENARIO] A Dynamics 365 Administrator keeps SUPER on first login even when the assigned license plan + // configuration is customized and the customization omits SUPER. + Initialize(); + LibraryLowerPermissions.SetOutsideO365Scope(); + LibraryLowerPermissions.AddSecurity(); + + // [GIVEN] A first-login super user provisioned with a Premium plan and the internal D365 Admin plan + CreateUserWithPlan(User, PlanIds.GetPremiumPlanId()); + AddSubscriptionPlanToUser(User, PlanIds.GetD365AdminPlanId()); + + // [GIVEN] The Premium plan configuration is customized and does not contain SUPER + PlanConfigurationLibrary.AddConfiguration(PlanIds.GetPremiumPlanId(), true); + + // [WHEN] RefreshUserPlanAssignments is invoked (first login flow) + LibraryLowerPermissions.SetO365BusFull(); + LibraryLowerPermissions.AddSecurity(); + AzureADPlan.RefreshUserPlanAssignments(User."User Security ID"); + + // [THEN] The internal admin still has SUPER + Assert.IsTrue(IsUserInPermissionSet(User."User Security ID", 'SUPER'), 'Dynamics 365 Administrator should retain SUPER when the customized plan omits SUPER.'); + + // Rollback SaaS test + TearDown(); + end; + + [Test] + [TransactionModel(TransactionModel::AutoRollback)] + [CommitBehavior(CommitBehavior::Ignore)] + [Scope('OnPrem')] + procedure TestUserLosesSuperWhenCustomizedPlanOmitsSuper() + var + User: Record User; + AzureADPlan: Codeunit "Azure AD Plan"; + PlanConfigurationLibrary: Codeunit "Plan Configuration Library"; + PlanIds: Codeunit "Plan Ids"; + begin + // [SCENARIO] An ordinary (non-admin) user loses SUPER on first login when the assigned license plan + // configuration is customized and the customization omits SUPER. + Initialize(); + LibraryLowerPermissions.SetOutsideO365Scope(); + LibraryLowerPermissions.AddSecurity(); + + // [GIVEN] A first-login super user provisioned with a Premium plan only (no admin plan) + CreateUserWithPlan(User, PlanIds.GetPremiumPlanId()); + + // [GIVEN] The Premium plan configuration is customized and does not contain SUPER + PlanConfigurationLibrary.AddConfiguration(PlanIds.GetPremiumPlanId(), true); + + // [WHEN] RefreshUserPlanAssignments is invoked (first login flow) + LibraryLowerPermissions.SetO365BusFull(); + LibraryLowerPermissions.AddSecurity(); + AzureADPlan.RefreshUserPlanAssignments(User."User Security ID"); + + // [THEN] The ordinary user no longer has SUPER + Assert.IsFalse(IsUserInPermissionSet(User."User Security ID", 'SUPER'), 'Ordinary user should lose SUPER when the customized plan omits SUPER.'); + + // Rollback SaaS test + TearDown(); + end; + + [Test] + [TransactionModel(TransactionModel::AutoRollback)] + [CommitBehavior(CommitBehavior::Ignore)] + [Scope('OnPrem')] + procedure TestUserKeepsSuperWhenCustomizedPlanContainsSuper() + var + User: Record User; + AzureADPlan: Codeunit "Azure AD Plan"; + PlanConfiguration: Codeunit "Plan Configuration"; + PlanConfigurationLibrary: Codeunit "Plan Configuration Library"; + PlanIds: Codeunit "Plan Ids"; + NullGuid: Guid; + begin + // [SCENARIO] Any user keeps SUPER on first login when the customized license plan configuration explicitly + // contains SUPER, regardless of admin status. + Initialize(); + LibraryLowerPermissions.SetOutsideO365Scope(); + LibraryLowerPermissions.AddSecurity(); + + // [GIVEN] A first-login super user provisioned with a Premium plan only (no admin plan) + CreateUserWithPlan(User, PlanIds.GetPremiumPlanId()); + + // [GIVEN] The Premium plan configuration is customized and explicitly contains SUPER + PlanConfigurationLibrary.AddConfiguration(PlanIds.GetPremiumPlanId(), true); + PlanConfiguration.AddCustomPermissionSetToPlan(PlanIds.GetPremiumPlanId(), 'SUPER', NullGuid, 0, ''); + + // [WHEN] RefreshUserPlanAssignments is invoked (first login flow) + LibraryLowerPermissions.SetO365BusFull(); + LibraryLowerPermissions.AddSecurity(); + AzureADPlan.RefreshUserPlanAssignments(User."User Security ID"); + + // [THEN] The user still has SUPER + Assert.IsTrue(IsUserInPermissionSet(User."User Security ID", 'SUPER'), 'User should retain SUPER when the customized plan contains SUPER.'); + + // Rollback SaaS test + TearDown(); + end; + + [Test] + [TransactionModel(TransactionModel::AutoRollback)] + [CommitBehavior(CommitBehavior::Ignore)] + [Scope('OnPrem')] + procedure TestDelegatedAdminLosesSuperWhenCustomizedPlanOmitsSuper() + var + User: Record User; + AzureADPlan: Codeunit "Azure AD Plan"; + PlanConfigurationLibrary: Codeunit "Plan Configuration Library"; + PlanIds: Codeunit "Plan Ids"; + begin + // [SCENARIO] A delegated (partner) admin is NOT treated as an internal tenant admin by the first-login flow: + // when the assigned license plan configuration is customized and omits SUPER, SUPER is removed. + // Delegated admins retain SUPER on default (non-customized) plans - see TestDelegatedAdminIsSuperIfSuperExists - + // and their delegated-role provisioning is covered separately in the Azure AD Plan system app tests. + Initialize(); + LibraryLowerPermissions.SetOutsideO365Scope(); + LibraryLowerPermissions.AddSecurity(); + + // [GIVEN] A first-login super user provisioned with a Premium plan and the delegated admin plan + CreateUserWithPlan(User, PlanIds.GetPremiumPlanId()); + AddSubscriptionPlanToUser(User, PlanIds.GetDelegatedAdminPlanId()); + + // [GIVEN] The Premium plan configuration is customized and does not contain SUPER + PlanConfigurationLibrary.AddConfiguration(PlanIds.GetPremiumPlanId(), true); + + // [WHEN] RefreshUserPlanAssignments is invoked (first login flow) + LibraryLowerPermissions.SetO365BusFull(); + LibraryLowerPermissions.AddSecurity(); + AzureADPlan.RefreshUserPlanAssignments(User."User Security ID"); + + // [THEN] The delegated admin loses SUPER (not exempted like an internal tenant admin) + Assert.IsFalse(IsUserInPermissionSet(User."User Security ID", 'SUPER'), 'Delegated admin should lose SUPER when the customized plan omits SUPER.'); + + // Rollback SaaS test + TearDown(); + end; + + [Test] + [TransactionModel(TransactionModel::AutoRollback)] + [CommitBehavior(CommitBehavior::Ignore)] + [Scope('OnPrem')] + procedure TestExistingUserKeepsSuperWhenCustomizedPlanOmitsSuper() + var + User: Record User; + AzureADPlan: Codeunit "Azure AD Plan"; + PlanConfigurationLibrary: Codeunit "Plan Configuration Library"; + PlanIds: Codeunit "Plan Ids"; + begin + // [SCENARIO] An already-provisioned (non first-login) user keeps SUPER even when the assigned license plan + // configuration is customized and omits SUPER - SUPER is only ever revoked during first-login provisioning. + Initialize(); + LibraryLowerPermissions.SetOutsideO365Scope(); + LibraryLowerPermissions.AddSecurity(); + + // [GIVEN] A super user that has already been set up before (has a plan assigned in BC) + CreateUserWithPlan(User, PlanIds.GetPremiumPlanId()); + LibraryPermissions.AddUserToPlan(User."User Security ID", PlanIds.GetPremiumPlanId()); + + // [GIVEN] The Premium plan configuration is customized and does not contain SUPER + PlanConfigurationLibrary.AddConfiguration(PlanIds.GetPremiumPlanId(), true); + + // [WHEN] RefreshUserPlanAssignments is invoked (existing user flow) + LibraryLowerPermissions.SetO365BusFull(); + LibraryLowerPermissions.AddSecurity(); + AzureADPlan.RefreshUserPlanAssignments(User."User Security ID"); + + // [THEN] The already-provisioned user still has SUPER + Assert.IsTrue(IsUserInPermissionSet(User."User Security ID", 'SUPER'), 'Existing (non first-login) user should retain SUPER regardless of plan customization.'); + + // Rollback SaaS test + TearDown(); + end; + [Test] [TransactionModel(TransactionModel::AutoRollback)] [CommitBehavior(CommitBehavior::Ignore)] @@ -1138,6 +1357,20 @@ codeunit 139509 "Azure AD Plan Module Test" MockGraphQueryTestLibrary.AddGraphUser(GetUserAuthenticationId(User), User."User Name", '', '', Plan.Plan_ID, Plan.Plan_Name, 'Enabled'); end; + local procedure AddSubscriptionPlanToUser(User: Record User; PlanID: Guid) + var + Plan: Query Plan; + begin + // Adds an additional Azure AD subscription plan to an already-created mock graph user, so that a user can be + // provisioned with several plans at once (e.g. a Premium plan together with an internal/delegated admin plan). + Plan.SetRange(Plan_ID, PlanID); + Plan.Open(); + Plan.Read(); + + MockGraphQueryTestLibrary.AddUserPlan(GetUserAuthenticationId(User), Plan.Plan_ID, Plan.Plan_Name, 'Enabled'); + Plan.Close(); + end; + local procedure IsUserInPermissionSet(UserID: Guid; PermissionSetCode: Text): Boolean var AccessControl: Record "Access Control"; diff --git a/src/System Application/App/Azure AD Plan/src/AzureADPlanImpl.Codeunit.al b/src/System Application/App/Azure AD Plan/src/AzureADPlanImpl.Codeunit.al index 515f5ae2d64..f9e6454bfb7 100644 --- a/src/System Application/App/Azure AD Plan/src/AzureADPlanImpl.Codeunit.al +++ b/src/System Application/App/Azure AD Plan/src/AzureADPlanImpl.Codeunit.al @@ -765,7 +765,12 @@ codeunit 9018 "Azure AD Plan Impl." Session.LogMessage('0000GYC', ClearPersonalizationTxt, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', UserSetupCategoryTxt); end; - ShouldRemoveSuper := not PlanConfigurationContainsSuper + // Internal tenant administrators (Global Admin, D365 Admin, BC Admin) must never lose SUPER during + // provisioning, even when the assigned plan configuration is customized and omits SUPER - otherwise + // they could be locked out of the environment. Everyone else follows the customized configuration: + // SUPER is removed unless the configuration explicitly grants it. Delegated (partner) admins are + // intentionally not exempted here; their SUPER is governed separately in AssignPlanToUserWithDelegatedRole. + ShouldRemoveSuper := (not PlanConfigurationContainsSuper) and (not IsUserInternalAdmin(UserSecurityID)) end else ShouldRemoveSuper := not IsUserAdmin(UserSecurityID); @@ -803,12 +808,27 @@ codeunit 9018 "Azure AD Plan Impl." var PlanIds: Codeunit "Plan Ids"; begin + // Note: this also treats delegated (partner) admins as admins. Use IsUserInternalAdmin when only + // internal tenant administrators should be considered (e.g. the SUPER guard for customized plans). exit( IsPlanAssignedToUser(PlanIds.GetGlobalAdminPlanId(), SecurityID) or IsPlanAssignedToUser(PlanIds.GetDelegatedAdminPlanId(), SecurityID) or IsPlanAssignedToUser(PlanIds.GetD365AdminPlanId(), SecurityID)); end; + [NonDebuggable] + local procedure IsUserInternalAdmin(SecurityID: Guid): Boolean + var + PlanIds: Codeunit "Plan Ids"; + begin + // Internal tenant administrator plans only. Delegated (partner) admin/helpdesk roles are intentionally + // excluded: their SUPER handling is governed separately in AssignPlanToUserWithDelegatedRole. + exit( + IsPlanAssignedToUser(PlanIds.GetGlobalAdminPlanId(), SecurityID) + or IsPlanAssignedToUser(PlanIds.GetD365AdminPlanId(), SecurityID) + or IsPlanAssignedToUser(PlanIds.GetBCAdminPlanId(), SecurityID)); + end; + [NonDebuggable] procedure GetPlanIDs(GraphUserInfo: DotNet UserInfo; var PlanIDs: List of [Guid]) var From 5e95271ecdefef3b58bb9a72bdc4c6e8f13c0d16 Mon Sep 17 00:00:00 2001 From: Darrick Joo Date: Mon, 27 Jul 2026 09:22:43 +0200 Subject: [PATCH 2/3] Clear existing plan configuration before customizing in SUPER retention tests The Premium plan already has a default Plan Configuration record created at app install, so AddConfiguration (a plain Insert) failed with a duplicate primary key. Clear plan configurations first, matching the pattern used by the Azure AD Plan system application tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../test/src/AzureADPlanModuleTest.codeunit.al | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Apps/W1/PlanConfiguration/test/src/AzureADPlanModuleTest.codeunit.al b/src/Apps/W1/PlanConfiguration/test/src/AzureADPlanModuleTest.codeunit.al index dbe30a85532..febbe103d4c 100644 --- a/src/Apps/W1/PlanConfiguration/test/src/AzureADPlanModuleTest.codeunit.al +++ b/src/Apps/W1/PlanConfiguration/test/src/AzureADPlanModuleTest.codeunit.al @@ -852,6 +852,7 @@ codeunit 139509 "Azure AD Plan Module Test" AddSubscriptionPlanToUser(User, PlanIds.GetGlobalAdminPlanId()); // [GIVEN] The Premium plan configuration is customized and does not contain SUPER + PlanConfigurationLibrary.ClearPlanConfigurations(); PlanConfigurationLibrary.AddConfiguration(PlanIds.GetPremiumPlanId(), true); // [WHEN] RefreshUserPlanAssignments is invoked (first login flow) @@ -888,6 +889,7 @@ codeunit 139509 "Azure AD Plan Module Test" AddSubscriptionPlanToUser(User, PlanIds.GetD365AdminPlanId()); // [GIVEN] The Premium plan configuration is customized and does not contain SUPER + PlanConfigurationLibrary.ClearPlanConfigurations(); PlanConfigurationLibrary.AddConfiguration(PlanIds.GetPremiumPlanId(), true); // [WHEN] RefreshUserPlanAssignments is invoked (first login flow) @@ -923,6 +925,7 @@ codeunit 139509 "Azure AD Plan Module Test" CreateUserWithPlan(User, PlanIds.GetPremiumPlanId()); // [GIVEN] The Premium plan configuration is customized and does not contain SUPER + PlanConfigurationLibrary.ClearPlanConfigurations(); PlanConfigurationLibrary.AddConfiguration(PlanIds.GetPremiumPlanId(), true); // [WHEN] RefreshUserPlanAssignments is invoked (first login flow) @@ -960,6 +963,7 @@ codeunit 139509 "Azure AD Plan Module Test" CreateUserWithPlan(User, PlanIds.GetPremiumPlanId()); // [GIVEN] The Premium plan configuration is customized and explicitly contains SUPER + PlanConfigurationLibrary.ClearPlanConfigurations(); PlanConfigurationLibrary.AddConfiguration(PlanIds.GetPremiumPlanId(), true); PlanConfiguration.AddCustomPermissionSetToPlan(PlanIds.GetPremiumPlanId(), 'SUPER', NullGuid, 0, ''); @@ -999,6 +1003,7 @@ codeunit 139509 "Azure AD Plan Module Test" AddSubscriptionPlanToUser(User, PlanIds.GetDelegatedAdminPlanId()); // [GIVEN] The Premium plan configuration is customized and does not contain SUPER + PlanConfigurationLibrary.ClearPlanConfigurations(); PlanConfigurationLibrary.AddConfiguration(PlanIds.GetPremiumPlanId(), true); // [WHEN] RefreshUserPlanAssignments is invoked (first login flow) @@ -1035,6 +1040,7 @@ codeunit 139509 "Azure AD Plan Module Test" LibraryPermissions.AddUserToPlan(User."User Security ID", PlanIds.GetPremiumPlanId()); // [GIVEN] The Premium plan configuration is customized and does not contain SUPER + PlanConfigurationLibrary.ClearPlanConfigurations(); PlanConfigurationLibrary.AddConfiguration(PlanIds.GetPremiumPlanId(), true); // [WHEN] RefreshUserPlanAssignments is invoked (existing user flow) From 176cb78c11b39369867d7632fe0965d86d0566e5 Mon Sep 17 00:00:00 2001 From: Darrick Joo Date: Tue, 28 Jul 2026 14:22:32 +0200 Subject: [PATCH 3/3] Add BC Admin branch coverage to SUPER retention tests IsUserInternalAdmin gates SUPER retention on Global Admin, D365 Admin, and BC Admin plans, but the regression tests only covered the first two. Add a test using PlanIds.GetBCAdminPlanId() so the Internal BC Administrator branch is exercised too. Mirrors the existing internal-admin retention tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/AzureADPlanModuleTest.codeunit.al | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/Apps/W1/PlanConfiguration/test/src/AzureADPlanModuleTest.codeunit.al b/src/Apps/W1/PlanConfiguration/test/src/AzureADPlanModuleTest.codeunit.al index febbe103d4c..428d316a5b2 100644 --- a/src/Apps/W1/PlanConfiguration/test/src/AzureADPlanModuleTest.codeunit.al +++ b/src/Apps/W1/PlanConfiguration/test/src/AzureADPlanModuleTest.codeunit.al @@ -904,6 +904,43 @@ codeunit 139509 "Azure AD Plan Module Test" TearDown(); end; + [Test] + [TransactionModel(TransactionModel::AutoRollback)] + [CommitBehavior(CommitBehavior::Ignore)] + [Scope('OnPrem')] + procedure TestBCAdminKeepsSuperWhenCustomizedPlanOmitsSuper() + var + User: Record User; + AzureADPlan: Codeunit "Azure AD Plan"; + PlanConfigurationLibrary: Codeunit "Plan Configuration Library"; + PlanIds: Codeunit "Plan Ids"; + begin + // [SCENARIO] An Internal BC Administrator keeps SUPER on first login even when the assigned license plan + // configuration is customized and the customization omits SUPER. + Initialize(); + LibraryLowerPermissions.SetOutsideO365Scope(); + LibraryLowerPermissions.AddSecurity(); + + // [GIVEN] A first-login super user provisioned with a Premium plan and the internal BC Admin plan + CreateUserWithPlan(User, PlanIds.GetPremiumPlanId()); + AddSubscriptionPlanToUser(User, PlanIds.GetBCAdminPlanId()); + + // [GIVEN] The Premium plan configuration is customized and does not contain SUPER + PlanConfigurationLibrary.ClearPlanConfigurations(); + PlanConfigurationLibrary.AddConfiguration(PlanIds.GetPremiumPlanId(), true); + + // [WHEN] RefreshUserPlanAssignments is invoked (first login flow) + LibraryLowerPermissions.SetO365BusFull(); + LibraryLowerPermissions.AddSecurity(); + AzureADPlan.RefreshUserPlanAssignments(User."User Security ID"); + + // [THEN] The internal admin still has SUPER + Assert.IsTrue(IsUserInPermissionSet(User."User Security ID", 'SUPER'), 'Internal BC Administrator should retain SUPER when the customized plan omits SUPER.'); + + // Rollback SaaS test + TearDown(); + end; + [Test] [TransactionModel(TransactionModel::AutoRollback)] [CommitBehavior(CommitBehavior::Ignore)]