Codeunit 9871 Security Group Impl., source in 25

Source242526272829metadata in 25

src/System Application/App/Security Groups/src/SecurityGroupImpl.Codeunit.al621 lines, Copyright (c) Microsoft Corporation. MIT

// ------------------------------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.
// ------------------------------------------------------------------------------------------------

namespace System.Security.AccessControl;

using System;
using System.Telemetry;
using System.Azure.Identity;

codeunit 9871 "Security Group Impl."
{
    Access = Internal;
    InherentEntitlements = X;
    InherentPermissions = X;
    Permissions = tabledata "Security Group" = rimd,
                  tabledata User = rimd,
                  tabledata "User Property" = rimd;

    var
        AzureAdGraph: Codeunit "Azure AD Graph";
        EntraGroups: Dictionary of [Text, Text];
        AreAllEntraGroupsFetched: Boolean;
        InvalidWindowsGroupErr: Label 'The group ID %1 does not correspond to a valid Windows group.', Comment = '%1 = Windows group ID';
        InvalidEntraGroupErr: Label 'The group ID %1 does not correspond to a valid Microsoft Entra group.', Comment = '%1 = Microsoft Entra security group ID';
        InvalidGroupNameErr: Label 'The group %1 could not be found.', Comment = '%1 = group name';
        GroupAlreadyExistsErr: Label 'The group %1 already exists.', Comment = '%1 = group name or group code';
        WindowsAccountNotAllowedErr: Label 'The group %1 is not allowed.', Comment = '%1 = Windows group name';
        CouldNotFindGroupErr: Label 'Could not find %1 group.', Comment = '%1 = Active Directory / Microsoft Entra';
        NoPermissionsErr: Label 'You do not have permissions to create security groups. Ask your system administrator to give you Insert, Modify and Delete permissions for the Security Group table.';
        GroupNotFoundTxt: Label 'The group %1 could not be found in %2. The permission sets assigned to it will not have any effect.', Comment = '%1 = group code; %2 = Active Directory / Microsoft Entra';
        GroupsNotFoundTxt: Label 'The groups %1 could not be found in %2. The permission sets assigned to them will not have any effect.', Comment = '%1 = comma separated list of group codes; %2 = Active Directory / Microsoft Entra';
        SecurityGroupsTok: Label 'Security Groups', Locked = true;
        AdTxt: Label 'Active Directory', Locked = true;
        EntraTxt: Label 'Microsoft Entra', Locked = true;
        SecurityGroupAddedLbl: Label 'A security group with ID %1 has been added. Automatically created user with security ID: %2.', Locked = true;
        RemovingOrphanedGroupsTxt: Label 'Removing %1 orphaned security groups.', Locked = true;
        CouldNotRemoveGroupErr: Label 'Could not delete an orphaned security group.', Locked = true;
        NotificationIdLbl: Label 'e78ecb57-f560-4788-b9c7-e5a477467d65', Locked = true;

    procedure ValidateGroupId(GroupId: Text)
    begin
        if IsWindowsAuthentication() then
            ValidateWindowsGroup(GroupId)
        else
            ValidateEntraGroup(GroupId);
    end;

    procedure Create(GroupCode: Code[20]; GroupId: Text)
    var
        SecurityGroup: Record "Security Group";
        [SecurityFiltering(SecurityFilter::Ignored)]
        SecurityGroup2: Record "Security Group";
        SecurityGroupUser: Record User;
        FeatureTelemetry: Codeunit "Feature Telemetry";
        NavUserAccountHelper: DotNet NavUserAccountHelper;
        Handled: Boolean;
        EntraGroupName: Text[250];
    begin
        // CreateUserFromAAdGroupObjectId will commit the changes to the User table, so we need to
        // make sure there will not be a permission error when inserting a security group record.
        if not SecurityGroup2.WritePermission() then
            Error(NoPermissionsErr);

        if SecurityGroup.Get(GroupCode) then
            Error(GroupAlreadyExistsErr, GroupCode);

        if IsWindowsAuthentication() then begin
            ValidateWindowsGroup(GroupId);

            SecurityGroupUser."User Security ID" := CreateGuid();
            SecurityGroupUser."Windows Security ID" := CopyStr(GroupId, 1, MaxStrLen(SecurityGroupUser."Windows Security ID"));
            SecurityGroupUser."License Type" := SecurityGroupUser."License Type"::"Windows Group";
            SecurityGroupUser."User Name" := CopyStr(NavUserAccountHelper.UserName(GroupId), 1, MaxStrLen(SecurityGroupUser."User Name"));
            SecurityGroupUser.Insert();

            SecurityGroup."Group User SID" := SecurityGroupUser."User Security ID";
        end else begin
            ValidateEntraGroup(GroupId);
            TryGetNameById(GroupId, EntraGroupName);

            OnBeforeCreateAadGroupUserInSaaS(SecurityGroup, GroupId, EntraGroupName, Handled);
            if not Handled then
                SecurityGroup."Group User SID" := NavUserAccountHelper.CreateUserFromAAdGroupObjectId(GroupId, EntraGroupName);
        end;

        SecurityGroup.Code := GroupCode;
        SecurityGroup.Insert();

        FeatureTelemetry.LogUptake('0000JGO', SecurityGroupsTok, Enum::"Feature Uptake Status"::"Set up");
        Session.LogSecurityAudit(SecurityGroupsTok, SecurityOperationResult::Success, StrSubstNo(SecurityGroupAddedLbl, GroupId, SecurityGroupUser."User Security ID"), AuditCategory::UserManagement);
    end;

    procedure Delete(GroupCode: Code[20])
    var
        SecurityGroup: Record "Security Group";
        User: Record User;
        AccessControl: Record "Access Control";
    begin
        if SecurityGroup.Get(GroupCode) then begin
            AccessControl.SetRange("User Security ID", SecurityGroup."Group User SID");
            AccessControl.DeleteAll();
            if User.Get(SecurityGroup."Group User SID") then
                User.Delete();
            SecurityGroup.Delete(true);
        end;
    end;

    procedure Copy(SourceGroupCode: Code[20]; DestinationGroupCode: Code[20]; DestinationGroupId: Text)
    begin
        Create(DestinationGroupCode, DestinationGroupId);
        CopyPermissions(SourceGroupCode, DestinationGroupCode);
    end;

    procedure CopyPermissions(SourceGroupCode: Code[20]; DestinationGroupCode: Code[20])
    var
        AccessControlSource: Record "Access Control";
        AccessControlDest: Record "Access Control";
        DestSecurityGroupUserSecId: Guid;
    begin
        DestSecurityGroupUserSecId := GetGroupUserSecurityId(DestinationGroupCode);
        AccessControlSource.SetRange("User Security ID", GetGroupUserSecurityId(SourceGroupCode));
        if AccessControlSource.FindSet() then
            repeat
                if not AccessControlDest.Get(DestSecurityGroupUserSecId, AccessControlSource."Role ID", AccessControlSource."Company Name", AccessControlSource.Scope, AccessControlSource."App ID") then begin
                    AccessControlDest.Copy(AccessControlSource);
                    AccessControlDest."User Security ID" := DestSecurityGroupUserSecId;
                    AccessControlDest.Insert();
                end;
            until AccessControlSource.Next() = 0;
    end;

    procedure AddPermissionSet(GroupCode: Code[20]; RoleId: Code[20]; Company: Text[30]; Scope: Option System,Tenant; AppId: Guid)
    var
        SecurityGroup: Record "Security Group";
        AccessControl: Record "Access Control";
    begin
        SecurityGroup.Get(GroupCode);

        if not AccessControl.Get(SecurityGroup."Group User SID", RoleId, Company, Scope, AppId) then begin
            AccessControl."User Security ID" := SecurityGroup."Group User SID";
            AccessControl."Role ID" := RoleId;
            AccessControl."Company Name" := Company;
            AccessControl.Scope := Scope;
            AccessControl."App ID" := AppId;
            AccessControl.Insert();
        end;
    end;

    procedure RemovePermissionSet(GroupCode: Code[20]; RoleId: Code[20]; Company: Text[30]; Scope: Option System,Tenant; AppId: Guid): Boolean
    var
        SecurityGroup: Record "Security Group";
        AccessControl: Record "Access Control";
    begin
        SecurityGroup.Get(GroupCode);

        if AccessControl.Get(SecurityGroup."Group User SID", RoleId, Company, Scope, AppId) then begin
            AccessControl.Delete();
            exit(true);
        end;

        exit(false);
    end;

    procedure GetGroupUserSecurityId(GroupCode: Code[20]): Guid
    var
        SecurityGroup: Record "Security Group";
    begin
        SecurityGroup.Get(GroupCode);
        exit(SecurityGroup."Group User SID");
    end;

    procedure Export(SecurityGroupCodes: List of [Code[20]]; Destination: OutStream)
    var
        SecurityGroup: Record "Security Group";
        ExportImportSecurityGroups: XmlPort "Export/Import Security Groups";
        SecurityGroupFilterTextBuilder: TextBuilder;
        GroupCode: Code[20];
    begin
        foreach GroupCode in SecurityGroupCodes do begin
            SecurityGroupFilterTextBuilder.Append(GroupCode);
            SecurityGroupFilterTextBuilder.Append('|');
        end;
        SecurityGroup.SetFilter(Code, SecurityGroupFilterTextBuilder.ToText().TrimEnd('|'));

        ExportImportSecurityGroups.SetTableView(SecurityGroup);
        ExportImportSecurityGroups.SetDestination(Destination);
        ExportImportSecurityGroups.Export();
    end;

    procedure Import(Source: InStream)
    var
        ExportImportSecurityGroups: XmlPort "Export/Import Security Groups";
    begin
        ExportImportSecurityGroups.SetSource(Source);
        ExportImportSecurityGroups.Import();
    end;

    procedure GetAvailableGroups(var SecurityGroupBuffer: Record "Security Group Buffer")
    var
        User: Record User;
        UserProperty: Record "User Property";
        [SecurityFiltering(SecurityFilter::Ignored)]
        DummySecurityGroup: Record "Security Group";
        LocalSecurityGroupBuffer: Record "Security Group Buffer";
        EntraGroupId: Text;
        NavUserAccountHelper: DotNet NavUserAccountHelper;
        LocalWindowsGroupName: Text;
    begin
        // Prevent non-admin users from seeing the list of all available groups
        if not DummySecurityGroup.WritePermission() then
            Error(NoPermissionsErr);

        LocalSecurityGroupBuffer.Copy(SecurityGroupBuffer, true);
        LocalSecurityGroupBuffer.Reset();
        LocalSecurityGroupBuffer.DeleteAll();

        if IsWindowsAuthentication() then
            foreach LocalWindowsGroupName in NavUserAccountHelper.GetLocalWindowsGroups() do begin
                SecurityGroupBuffer."Group ID" := CopyStr(SID(LocalWindowsGroupName), 1, MaxStrLen(SecurityGroupBuffer."Group ID"));
                User.SetRange("Windows Security ID", SecurityGroupBuffer."Group ID");
                if User.IsEmpty() then
                    if not GetDisallowedWindowsGroupIds().Contains(SecurityGroupBuffer."Group ID") then begin
                        SecurityGroupBuffer.Code := CopyStr(CreateGuid(), 1, MaxStrLen(SecurityGroupBuffer.Code));
                        SecurityGroupBuffer."Group Name" := CopyStr(LocalWindowsGroupName, 1, MaxStrLen(SecurityGroupBuffer."Group Name"));
                        SecurityGroupBuffer.Insert();
                    end;
            end
        else begin
            FetchAllEntraGroups();
            RemoveOrphanedEntraGroups();

            foreach EntraGroupId in EntraGroups.Keys do begin
                UserProperty.SetRange("Authentication Object ID", EntraGroupId);
                if UserProperty.IsEmpty() then begin
                    SecurityGroupBuffer.Code := CopyStr(CreateGuid(), 1, MaxStrLen(SecurityGroupBuffer.Code));
                    SecurityGroupBuffer."Group ID" := CopyStr(EntraGroupId, 1, MaxStrLen(SecurityGroupBuffer."Group ID"));
                    SecurityGroupBuffer."Group Name" := CopyStr(EntraGroups.Get(EntraGroupId), 1, MaxStrLen(SecurityGroupBuffer."Group Name"));
                    SecurityGroupBuffer.Insert();
                end;
            end;
        end;

        if SecurityGroupBuffer.FindFirst() then; // reset to the first record
    end;

    procedure GetGroups(var SecurityGroupBuffer: Record "Security Group Buffer"; FetchGroupNames: Boolean)
    var
        SecurityGroup: Record "Security Group";
        LocalSecurityGroupBuffer: Record "Security Group Buffer";
    begin
        LocalSecurityGroupBuffer.Copy(SecurityGroupBuffer, true);
        LocalSecurityGroupBuffer.Reset();
        LocalSecurityGroupBuffer.DeleteAll();

        if IsWindowsAuthentication() then
            SecurityGroup.SetAutoCalcFields("Windows Group ID")
        else
            SecurityGroup.SetAutoCalcFields("AAD Group ID");

        if SecurityGroup.FindSet() then
            repeat
                SecurityGroupBuffer.Init();
                SecurityGroupBuffer.Code := SecurityGroup.Code;
                SecurityGroupBuffer."Group User SID" := SecurityGroup."Group User SID";
                if IsWindowsAuthentication() then
                    SecurityGroupBuffer."Group ID" := SecurityGroup."Windows Group ID"
                else
                    SecurityGroupBuffer."Group ID" := SecurityGroup."AAD Group ID";

                if FetchGroupNames then
                    if GetName(SecurityGroup.Code, SecurityGroupBuffer."Group Name") then
                        SecurityGroupBuffer."Retrieved Successfully" := true;
                SecurityGroupBuffer.Insert();
            until SecurityGroup.Next() = 0;

        if SecurityGroupBuffer.FindFirst() then; // reset to the first record
    end;

    procedure GetMembers(var SecurityGroupMemberBuffer: Record "Security Group Member Buffer"): List of [Code[20]]
    var
        SecurityGroup: Record "Security Group";
        LocalSecurityGroupMemberBuffer: Record "Security Group Member Buffer";
        FeatureTelemetry: Codeunit "Feature Telemetry";
        SkippedSecurityGroups: List of [Code[20]];
    begin
        LocalSecurityGroupMemberBuffer.Copy(SecurityGroupMemberBuffer, true);
        LocalSecurityGroupMemberBuffer.Reset();
        LocalSecurityGroupMemberBuffer.DeleteAll();

        if IsWindowsAuthentication() then
            SecurityGroup.SetAutoCalcFields("Windows Group ID")
        else
            SecurityGroup.SetAutoCalcFields("AAD Group ID");

        if not SecurityGroup.FindSet() then
            exit;

        repeat
            if IsWindowsAuthentication() then begin
                if not TryGetWindowsGroupMembers(SecurityGroup.Code, SecurityGroup."Windows Group ID", SecurityGroupMemberBuffer) then
                    SkippedSecurityGroups.Add(SecurityGroup.Code);
            end else
                if not TryGetEntraGroupMembers(SecurityGroup.Code, SecurityGroup."AAD Group ID", SecurityGroupMemberBuffer) then
                    SkippedSecurityGroups.Add(SecurityGroup.Code);
        until SecurityGroup.Next() = 0;

        if SecurityGroupMemberBuffer.FindFirst() then begin // reset to the first record
            // The group has members, which means that the permissions will be applied to member-users
            FeatureTelemetry.LogUptake('0000JGP', SecurityGroupsTok, Enum::"Feature Uptake Status"::Used);
            FeatureTelemetry.LogUsage('0000JGQ', SecurityGroupsTok, 'Security group members retrieved');
        end;

        exit(SkippedSecurityGroups);
    end;

    procedure GetName(GroupCode: Code[20]; var GroupName: Text[250]): Boolean
    begin
        if TryGetName(GroupCode, GroupName) then
            exit(true);
        exit(false);
    end;

    [TryFunction]
    local procedure TryGetName(GroupCode: Code[20]; var GroupName: Text[250])
    begin
        TryGetNameById(GetId(GroupCode), GroupName);
    end;

    [TryFunction]
    procedure TryGetNameById(GroupId: Text; var GroupName: Text[250])
    var
        NavUserAccountHelper: DotNet NavUserAccountHelper;
        GroupNameReturnedValue: Text;
    begin
        if IsWindowsAuthentication() then begin
            GroupNameReturnedValue := NavUserAccountHelper.UserName(GroupId);
            if GroupNameReturnedValue <> GroupId then
                GroupName := CopyStr(GroupNameReturnedValue, 1, MaxStrLen(GroupName))
            else
                Error(CouldNotFindGroupErr, AdTxt);
        end else begin
            if not EntraGroups.ContainsKey(GroupId) then begin
                GroupNameReturnedValue := AzureAdGraph.GetGroupName(GroupId);
                if GroupNameReturnedValue <> '' then
                    EntraGroups.Add(GroupId, GroupNameReturnedValue)
                else
                    Error(CouldNotFindGroupErr, EntraTxt);
            end;

            GroupName := CopyStr(EntraGroups.Get(GroupId), 1, MaxStrLen(GroupName))
        end;
    end;

    procedure GetId(GroupCode: Code[20]): Text[250]
    var
        SecurityGroup: Record "Security Group";
    begin
        if IsWindowsAuthentication() then begin
            SecurityGroup.SetAutoCalcFields("Windows Group ID");
            if SecurityGroup.Get(GroupCode) then
                exit(SecurityGroup."Windows Group ID");
        end else begin
            SecurityGroup.SetAutoCalcFields("AAD Group ID");
            if SecurityGroup.Get(GroupCode) then
                exit(SecurityGroup."AAD Group ID");
        end;
    end;

    procedure GetCode(GroupId: Text[250]; var GroupCode: Code[20]): Boolean
    var
        User: Record User;
        UserProperty: Record "User Property";
        SecurityGroup: Record "Security Group";
    begin
        if IsWindowsAuthentication() then begin
            User.SetRange("Windows Security ID", GroupId);
            if not User.FindFirst() then
                exit(false);
            SecurityGroup.SetRange("Group User SID", User."User Security ID");
        end else begin
            UserProperty.SetRange("Authentication Object ID", GroupId);
            if not UserProperty.FindFirst() then
                exit(false);
            SecurityGroup.SetRange("Group User SID", UserProperty."User Security ID");
        end;

        if not SecurityGroup.FindFirst() then
            exit(false);

        GroupCode := SecurityGroup.Code;
        exit(true);
    end;

    procedure GetIdByName(GroupName: Text): Text
    var
        GroupId: Text;
    begin
        if TryGetIdByName(GroupName, GroupId) then
            exit(GroupId)
        else
            Error(InvalidGroupNameErr, GroupName);
    end;

    [TryFunction]
    procedure TryGetIdByName(GroupName: Text; var GroupId: Text)
    begin
        if IsWindowsAuthentication() then
            GroupId := SID(GroupName)
        else begin
            // Microsoft Entra security group names are not unique, return the first one
            GroupId := AzureAdGraph.GetFirstGroupIdWithName(GroupName);
            EntraGroups.Add(GroupId, GroupName);
        end;
    end;

    [TryFunction]
    local procedure TryGetEntraGroupMembers(SecurityGroupCode: Code[20]; EntraGroupId: Text; var SecurityGroupMemberBuffer: Record "Security Group Member Buffer")
    var
        UserProperty: Record "User Property";
        UserIdsPage: DotNet UserIdsPage;
        UserEntraObjectId: Text;
    begin
        AzureAdGraph.GetMemberIdsPageForGroupId(EntraGroupId, 500, UserIdsPage);

        if IsNull(UserIdsPage) then
            exit;

        repeat
            foreach UserEntraObjectId in UserIdsPage.CurrentPage() do begin
                UserProperty.SetRange("Authentication Object ID", UserEntraObjectId);
                if UserProperty.FindFirst() then begin
                    SecurityGroupMemberBuffer."Security Group Code" := SecurityGroupCode;
                    SecurityGroupMemberBuffer."User Security ID" := UserProperty."User Security ID";
                    GetName(SecurityGroupCode, SecurityGroupMemberBuffer."Security Group Name");
                    SecurityGroupMemberBuffer.Insert();
                end;
            end;
        until (not UserIdsPage.GetNextMemberIdsPageForGroupId(EntraGroupId));
    end;

    local procedure ValidateWindowsGroup(WindowsGroupId: Text)
    var
        OtherUser: Record User;
        NavUserAccountHelper: DotNet NavUserAccountHelper;
        UserName: Text;
    begin
        if WindowsGroupId = '' then
            Error(InvalidWindowsGroupErr, WindowsGroupId);

        if StrLen(WindowsGroupId) > MaxStrLen(OtherUser."Windows Security ID") then
            Error(InvalidWindowsGroupErr, WindowsGroupId);

        UserName := NavUserAccountHelper.UserName(WindowsGroupId);
        if UserName = '' then
            Error(InvalidWindowsGroupErr, WindowsGroupId);

        if GetDisallowedWindowsGroupIds().Contains(WindowsGroupId) then
            Error(WindowsAccountNotAllowedErr, UserName);

        OtherUser.SetFilter("Windows Security ID", WindowsGroupId);
        if OtherUser.FindFirst() then
            Error(GroupAlreadyExistsErr, OtherUser."User Name");
    end;

    local procedure GetDisallowedWindowsGroupIds(): List of [Text]
    var
        DisallowedGroups: List of [Text];
    begin
        DisallowedGroups.Add('S-1-1-0'); // "World" - A group that includes all users.
        DisallowedGroups.Add('S-1-5-7'); // "Anonymous Logon"
        DisallowedGroups.Add('S-1-5-32-544'); // Administrators
        exit(DisallowedGroups);
    end;

    local procedure ValidateEntraGroup(EntraGroupObjectId: Text)
    var
        OtherUserProperty: Record "User Property";
        GroupName: Text[250];
    begin
        if not TryGetNameById(EntraGroupObjectId, GroupName) then
            Error(InvalidEntraGroupErr, EntraGroupObjectId);

        RemoveOrphanedEntraGroups();
        OtherUserProperty.SetRange("Authentication Object ID", EntraGroupObjectId);
        if not OtherUserProperty.IsEmpty() then
            Error(GroupAlreadyExistsErr, GroupName);
    end;

    local procedure FetchAllEntraGroups()
    begin
        // Fetching the groups can take a long time, so caching the results in a dictionary
        if AreAllEntraGroupsFetched then
            exit;

        EntraGroups := AzureAdGraph.GetGroups();
        AreAllEntraGroupsFetched := true;
    end;

    procedure IsWindowsAuthentication(): Boolean
    var
        NavUserAccountHelper: DotNet NavUserAccountHelper;
        IsWindowsAuth: Boolean;
        Handled: Boolean;
    begin
        OnIsWindowsAuthentication(IsWindowsAuth, Handled);
        if not Handled then
            IsWindowsAuth := NavUserAccountHelper.IsWindowsAuthentication();
        exit(IsWindowsAuth);
    end;

    [TryFunction]
    local procedure TryGetWindowsGroupMembers(SecurityGroupCode: Code[20]; WindowsGroupId: Text; var SecurityGroupMemberBuffer: Record "Security Group Member Buffer")
    var
        User: Record User;
        NavUserAccountHelper: DotNet NavUserAccountHelper;
        UserSID: Text;
        GroupName: Text;
    begin
        GroupName := NavUserAccountHelper.UserName(WindowsGroupId);
        foreach UserSID in NavUserAccountHelper.GetWindowsGroupMembersByName(GroupName) do begin
            User.SetRange("Windows Security ID", UserSID);
            if User.FindFirst() then begin
                SecurityGroupMemberBuffer."Security Group Code" := SecurityGroupCode;
                SecurityGroupMemberBuffer."User Security ID" := User."User Security ID";
                SecurityGroupMemberBuffer."Security Group Name" := CopyStr(GroupName, 1, MaxStrLen(SecurityGroupMemberBuffer."Security Group Name"));
                SecurityGroupMemberBuffer.Insert();
            end;
        end;
    end;

    procedure SendNotificationForDeletedGroups(var SecurityGroupBuffer: Record "Security Group Buffer")
    var
        LocalSecurityGroupBuffer: Record "Security Group Buffer";
        MissingGroupsNotification: Notification;
        GroupCodesTextBuilder: TextBuilder;
        MissingGroupCodes: Text;
    begin
        LocalSecurityGroupBuffer.Copy(SecurityGroupBuffer, true);
        LocalSecurityGroupBuffer.SetRange("Retrieved Successfully", false);

        if not LocalSecurityGroupBuffer.FindSet() then
            exit;

        repeat
            GroupCodesTextBuilder.Append(LocalSecurityGroupBuffer.Code);
            GroupCodesTextBuilder.Append(', ');
        until LocalSecurityGroupBuffer.Next() = 0;
        MissingGroupCodes := GroupCodesTextBuilder.ToText().TrimEnd(', ');

        if LocalSecurityGroupBuffer.Count() = 1 then begin
            if IsWindowsAuthentication() then
                MissingGroupsNotification.Message(StrSubstNo(GroupNotFoundTxt, MissingGroupCodes, AdTxt))
            else
                MissingGroupsNotification.Message(StrSubstNo(GroupNotFoundTxt, MissingGroupCodes, EntraTxt));
        end else
            if IsWindowsAuthentication() then
                MissingGroupsNotification.Message(StrSubstNo(GroupsNotFoundTxt, MissingGroupCodes, AdTxt))
            else
                MissingGroupsNotification.Message(StrSubstNo(GroupsNotFoundTxt, MissingGroupCodes, EntraTxt));

        MissingGroupsNotification.Id := NotificationIdLbl;
        MissingGroupsNotification.Scope := NotificationScope::LocalScope;
        MissingGroupsNotification.Send();
    end;

    procedure GetDesirableCode(GroupName: Text): Code[20]
    var
        GroupDomainAndNameList: List of [Text];
    begin
        if IsWindowsAuthentication() then begin
            GroupDomainAndNameList := GroupName.Split('\');
            exit(CopyStr(GroupDomainAndNameList.Get(GroupDomainAndNameList.Count()), 1, 20));
        end else
            exit(CopyStr(GroupName, 1, 20));
    end;

    /// <summary>
    /// Removes orphaned groups from the system. An orphaned group is a group that has no corresponding security group record.
    /// </summary>
    /// <remarks>
    /// It is possible to have a User record corresponding to a security group without a Security Group record,
    /// because the function NavUserAccountHelper.CreateUserFromAAdGroupObjectId inserts the user record in a separate session.
    /// So, if there is an error in the process after <see cref="Create"/> was called, the changes to the User table are not rolled back.
    /// </remarks>
    local procedure RemoveOrphanedEntraGroups();
    var
        GroupUser: Record User;
        SecurityGroup: Record "Security Group";
        OrphanedGroupUserSecurityIds: List of [Guid];
        OrphanedGroupUserSecurityId: Guid;
    begin
        GroupUser.SetRange("License Type", GroupUser."License Type"::"AAD Group");
        if GroupUser.FindSet() then
            repeat
                SecurityGroup.SetRange("Group User SID", GroupUser."User Security ID");
                if SecurityGroup.IsEmpty() then
                    OrphanedGroupUserSecurityIds.Add(GroupUser."User Security ID");
            until GroupUser.Next() = 0;

        if OrphanedGroupUserSecurityIds.Count() = 0 then
            exit;

        Session.LogMessage('0000LI8', StrSubstNo(RemovingOrphanedGroupsTxt, OrphanedGroupUserSecurityIds.Count()), Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', SecurityGroupsTok);
        foreach OrphanedGroupUserSecurityId in OrphanedGroupUserSecurityIds do
            if GroupUser.Get(OrphanedGroupUserSecurityId) then
                if not GroupUser.Delete(true) then
                    Session.LogMessage('0000M5L', CouldNotRemoveGroupErr, Verbosity::Error, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', SecurityGroupsTok);
    end;

    [InternalEvent(false)]
    local procedure OnIsWindowsAuthentication(var IsWindowsAuthentication: Boolean; var Handled: Boolean)
    begin
    end;

    [InternalEvent(false)]
    local procedure OnBeforeCreateAadGroupUserInSaaS(var SecurityGroup: Record "Security Group"; GroupId: Text; GroupName: Text; var Handled: Boolean)
    begin
    end;
}