diff --git a/src/Apps/W1/PlanConfiguration/test/src/AzureADPlanModuleTest.codeunit.al b/src/Apps/W1/PlanConfiguration/test/src/AzureADPlanModuleTest.codeunit.al index 98c2d078b4d..428d316a5b2 100644 --- a/src/Apps/W1/PlanConfiguration/test/src/AzureADPlanModuleTest.codeunit.al +++ b/src/Apps/W1/PlanConfiguration/test/src/AzureADPlanModuleTest.codeunit.al @@ -830,6 +830,268 @@ 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.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 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.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'), '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 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)] + [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.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 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.ClearPlanConfigurations(); + 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.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 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.ClearPlanConfigurations(); + 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 +1400,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