Thursday, April 27, 2023

Streamlining Content Hub User Management with the RemoveAdminUsers Script


As organizations grow and evolve, managing user access to digital assets becomes increasingly complex. In order to ensure data integrity and maintain security, it's crucial to keep track of which users have admin privileges and regulate access accordingly. To assist with this task, we've developed a powerful script called RemoveAdminUsers, designed to manage admin user access in Content Hub. In this blog post, we'll delve into the technical aspects of the RemoveAdminUsers script, discuss its functionality, and provide a comprehensive guide on how to use it.


Overview of the RemoveAdminUsers Script

The RemoveAdminUsers script is a versatile tool that helps organizations manage admin users in Content Hub more efficiently. The script offers several key features:

  • Disables admin users instead of removing them, ensuring a smooth audit process.
  • Generates a report for better management of admin users.
  • Streamlines the process of disabling admin users.

Here's a step-by-step breakdown of the RemoveAdminUsers script and how it works.

Step 1: Reading Email Addresses from a CSV File

The script starts by reading a list of email addresses from a CSV file, which serves as the input for determining which admin users should be disabled. This can easily be customized according to your organization's needs.


Step 2: Retrieving User Information Asynchronously

Next, the script asynchronously retrieves user information from Content Hub. It queries user profiles and processes them in parallel, which significantly speeds up the task. During this step, the script logs information about each user fetched, allowing you to monitor its progress.


Step 3: Disabling Admin Users

Once the user information has been fetched, the script proceeds to disable the admin users whose email addresses match those in the input CSV file. It's important to note that system admin users (such as certain Sitecore accounts) will not be disabled, ensuring that core system functionalities remain intact.


Step 4: Generating Reports

After disabling the relevant admin users, the script generates a report containing users that could not be disabled because they are system admin users. This list helps you keep track of any special accounts that require manual intervention.


using Dasync.Collections;
using ManyConsole;
using Microsoft.Extensions.Logging;
using Sitecore.CH.Base.Services;
using Stylelabs.M.Base.Querying;
using Stylelabs.M.Framework.Essentials.LoadConfigurations;
using Stylelabs.M.Framework.Essentials.LoadOptions;
using Stylelabs.M.Sdk.Contracts.Base;
using Stylelabs.M.Sdk.Contracts.Querying;
using Stylelabs.M.Sdk.WebClient;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using static Sitecore.CH.Implementation.CommandLine.Commands.SendEmailToAllUsers;
using static Sitecore.CH.Implementation.Constants.User;

namespace Sitecore.CH.Implementation.CommandLine.Commands
{
public class RemoveAdminUsers : ConsoleCommand
{
private readonly IWebMClient _client;
private readonly ILogger<RemoveAdminUsers> _logger;
private const int Success = 0;
private const string UserToUserProfile = "UserToUserProfile";
private const string UserNameValidCharactersPattern = "^[a-zA-Z0-9]*$";

private const string CsvFilePath = @"d:\\temp\\\prod\\q1_prod_-SuperUsersToValidate_Reviewed_26042023.csv";
private const string failedUsersCsvFilePath = @"d:\\temp\\prod\\failed_users.csv";
private const string reasonForRestriction = "User account has been temporarily disabled for auditing purposes";

public RemoveAdminUsers(IMClientFactory mClientFactory, ILogger<RemoveAdminUsers> logger)
{
IsCommand("RemoveAdminUsers", "Remove Users By Email");
this._client = mClientFactory.Client;
this._logger = logger;
}

public override int Run(string[] remainingArguments)
{
ExecuteAsync().Wait();
return Success;
}

private async Task ExecuteAsync()
{
IEnumerable<string> emailsToRemove = ReadEmailsFromCsv(CsvFilePath);
IEnumerable<UserInfo> users = await GetUserInfoAsync();
await RemoveAdminUsersAsync(users, emailsToRemove);
}

private IEnumerable<string> ReadEmailsFromCsv(string csvFilePath)
{
List<string> emails = new List<string>();

using (var reader = new StreamReader(csvFilePath))
{
while (!reader.EndOfStream)
{
var line = reader.ReadLine();
var values = line.Split(',');

if (values.Length > 1)
{
emails.Add(values[1].Trim());
}
}
}

return emails;
}

private async Task RemoveAdminUsersAsync(IEnumerable<UserInfo> users, IEnumerable<string> emailsToRemove)
{
var usersToRemove = users.Where(user => emailsToRemove.Contains(user.Email));
var failedUsers = new List<FailedUser>();

foreach (var user in usersToRemove)
{
try
{
IEntity userAccount = await _client.Users.GetUserAsync(user.UserName, new EntityLoadConfiguration
{
PropertyLoadOption = new PropertyLoadOption(Constants.User.Properties.IsRestricted, Constants.User.Properties.ReasonForRestriction)
});
                    //Make sure at least someone can still access it!
if (user.UserName != "MAIN_ADMIN_ACCOUNT")
{
userAccount.SetPropertyValue(Constants.User.Properties.IsRestricted, true);
userAccount.SetPropertyValue(Constants.User.Properties.ReasonForRestriction, reasonForRestriction);

await _client.Entities.SaveAsync(userAccount);
_logger.LogInformation($"User {user.UserName} - ({user.Email}) has been disabled.");
}
}
catch (Exception ex)
{
_logger.LogError($"Error removing user {user.UserName} ({user.Email}): {ex.Message}");
failedUsers.Add(new FailedUser { UserName = user.UserName, Email = user.Email, ErrorMessage = ex.Message });
}
}

// Write failed users to CSV file
if (failedUsers.Any())
{
using (var writer = new StreamWriter(failedUsersCsvFilePath))
{
writer.WriteLine("UserName,Email,ErrorMessage");

foreach (var user in failedUsers)
{
writer.WriteLine($"{user.UserName},{user.Email},{user.ErrorMessage}");
}
}
}
}

private async Task<IEnumerable<UserInfo>> GetUserInfoAsync()
{
Query userProfilesQuery = Query.CreateQuery(q =>
q.Where(e => e.DefinitionName == Constants.UserProfile.DefinitionName)
.OrderBy(e => e.CreatedOn).Take(2000));

IEntityQueryResult userProfilesQueryResult = await _client.Querying.QueryAsync(userProfilesQuery, new EntityLoadConfiguration
{
PropertyLoadOption = new PropertyLoadOption(Constants.UserProfile.Username, Constants.UserProfile.Email),
RelationLoadOption = new RelationLoadOption(UserToUserProfile)
});

ConcurrentBag<UserInfo> userInfoItems = new ConcurrentBag<UserInfo>();
int counter = 1;

// Process the user profiles asynchronously
await userProfilesQueryResult.Items.ParallelForEachAsync(entity =>
{
try
{
UserInfo userInfo = new UserInfo();
userInfo.UserName = entity.GetPropertyValue<string>(Constants.UserProfile.Username);
userInfo.Email = entity.GetPropertyValue<string>(Constants.UserProfile.Email);
userInfo.Id = entity.GetRelation<IChildToOneParentRelation>(UserToUserProfile).Parent.Value;

if (!string.IsNullOrWhiteSpace(userInfo.UserName) && !string.IsNullOrWhiteSpace(userInfo.Email))
{
userInfoItems.Add(userInfo);
_logger.LogInformation($"{Interlocked.Increment(ref counter)} - Fetching {userInfo.UserName} {userInfo.Email}");
}
}
catch (Exception ex)
{
_logger.LogError($"{Interlocked.Increment(ref counter)} - Error {ex.Message}");
}

return Task.CompletedTask;

}, maxDegreeOfParallelism: 1);

return userInfoItems;
}

public class FailedUser
{
public string UserName { get; set; }
public string Email { get; set; }
public string ErrorMessage { get; set; }
}
}
}


Conclusion:

The RemoveAdminUsers script is an invaluable tool for organizations that rely on Content Hub to manage digital assets. By automating the process of disabling admin users and generating insightful reports, the script simplifies user management and enhances overall security. We encourage you to leverage the power of RemoveAdminUsers to streamline your organization's user management processes and maintain a secure Content Hub environment.

Friday, April 21, 2023

Mastering the Art of Deploying a Single Azure Function: Simplifying Your Workflow


Hello, visionaries! Today, we are going to delve into the world of Azure Functions and the art of deploying them. The goal is to simplify your workflow and make the most out of your development experience.

We have observed that many developers face the challenge of deploying a single Azure Function within the same project. In most cases, you have to deploy all functions at once, which can be a bit cumbersome. However, we have found an elegant solution to this challenge.

As Steve Jobs once said, "Simple can be harder than complex: You have to work hard to get your thinking clean to make it simple. But it's worth it in the end because once you get there, you can move mountains." With that mindset, let's dive in.

In the Azure Functions world, the unit of deployment should ideally be the Function App, not individual functions. But what if the functions in the same project don't rely on each other and you don't want to deploy them together? Simple - split them into separate projects and deploy each project to a different Function App. The resources used by multiple Function Apps sharing the same App Service Plan won't differ significantly.

Now, let's discuss the risks of deploying a single function to a Function App with existing functions. You can technically deploy a single function from Visual Studio by simply right-clicking and excluding the functions you don't need to deploy. This works well if the Function App is empty or if you have selected "Remove additional files at destination."

However, if you uncheck that setting to retain previously deployed functions, you could face inconsistent behavior. The newly deployed function may overwrite some assemblies leveraged by existing functions, and useless files deleted locally might accumulate online due to the lack of deletion.

So, what's the elegant solution? We recommend setting up DevOps build and deploy processes to manage your functions or creating separate projects for each function. You can have a shared project for all common objects to avoid deployment complexities. By taking this approach, you can streamline your workflow and achieve the simplicity that Steve Jobs valued so much.

In conclusion, always strive for simplicity and elegance in your development process. As we've shown, even in the realm of Azure Functions, there are ways to overcome challenges and deploy single functions with ease. Happy coding, visionaries!

Unlocking the Potential of Sitecore Content Hub: Filtering Locked Assets with a Custom Azure Function

Introduction

Sitecore Content Hub is a powerful and flexible platform that helps organizations manage their digital assets efficiently. However, like any software, it may have certain limitations or quirks that users need to work around. In this blog post, we'll explore a common problem faced by Sitecore Content Hub users and provide an elegant solution that leverages the power of Azure Functions to filter locked assets from download orders.

The Problem

Imagine you're managing a large repository of digital assets in Sitecore Content Hub. You need to exclude assets marked as "isLocked = True" from download results and zip packages. However, you notice that the default filters on the "Download Order" page don't always produce consistent results. Further investigation reveals that in Sitecore Content Hub 4.2, the Selection on M.Asset using Order is unavailable, causing it to default to Archive.

The Solution

Fear not, dear content managers and developers! We've got a custom solution that will not only resolve the issue but also make your Sitecore Content Hub experience smoother. We'll create a custom filter to remove Assets with the field value "isLocked" set to True by modifying the entity through entity management ("/admin/entitymgmt/entity/51935"). We'll copy the default "System settings" and paste them into "Custom Settings" before saving the search component. Then, we'll add our filter for locked items. This workaround solves the problem, but we can make it even better by employing an Azure Function to ensure that locked items are not included in the zip output.

The Code: Unleashing the Power of Azure Functions

Our custom Azure Function, H_RemoveLockedAssetsFromDownloadOrder, is designed to filter out locked assets from download orders. It inherits from BaseTrigger and is triggered by an Action in response to a specific event. The function retrieves the target entity and its related job description, extracting the download order job description configuration as a JObject. It then iterates through the JArray in reverse, checking the lock status of each asset. If an asset is locked, it is removed from the array. The modified JArray is saved back into the job description configuration, and the processing job is resumed. By leveraging the power of Azure Functions, our custom solution provides a more reliable and efficient way to manage assets in Sitecore Content Hub. It's a win-win situation for both developers and managers alike.

using System.Threading.Tasks;

using Microsoft.AspNetCore.Mvc;

using Microsoft.Azure.WebJobs;

using Microsoft.Azure.WebJobs.Extensions.Http;

using Microsoft.AspNetCore.Http;

using Microsoft.Extensions.Logging;

using Sitecore.CH.Base.Services;

using Stylelabs.M.Sdk.Contracts.Base;

using Stylelabs.M.Framework.Essentials.LoadConfigurations;

using Sitecore.CH.Implementation.Services;

using System.Net.Http;

using Newtonsoft.Json.Linq;

using Stylelabs.M.Framework.Essentials.LoadOptions;

using System.Text;

using Microsoft.Identity.Client;


namespace Sitecore.CH.Implementation.AzFunctions.Functions

{

    public class H_RemoveLockedAssetsFromDownloadOrder : BaseTrigger

    {

        public H_RemoveLockedAssetsFromDownloadOrder(IMClientFactory mClientFactory,

                                        ILoggerService<H_SampleTriggerFunction> logger,

                                        ILoggingContextService loggingContextService) : base(mClientFactory, logger, loggingContextService)

        {

        }


        [FunctionName("H_RemoveLockedAssetsFromDownloadOrder")]

        public Task<IActionResult> EntryPoint([HttpTrigger(AuthorizationLevel.Function, "post", Route = null)] HttpRequest req, ILogger log)

        {

            return base.Run(req);

        }


        protected override async Task<IActionResult> Execute(HttpRequest req, IEntity entity)

        {

            _logger.LogInformation("Starting execution of H_RemoveLockedAssetsFromDownloadOrder...");


            var targetEntity = await _mClientFactory.Client.Entities.GetAsync(entity.Id.Value);

            await targetEntity.LoadRelationsAsync(new RelationLoadOption("JobToJobDescription")).ConfigureAwait(false);

            IParentToOneChildRelation jobToJobDescription = targetEntity.GetRelation<IParentToOneChildRelation>("JobToJobDescription");


            var id = jobToJobDescription.GetId().Value;

            IEntity jobDescription = await _mClientFactory.Client.Entities.GetAsync(id);


            // get download order job description configuration

            var config = await jobDescription.GetPropertyValueAsync("Job.Configuration"as JObject; ;

            JArray jarrayObj = config["entity"].ToObject<JArray>();


            _logger.LogInformation($"Initial assets count: {jarrayObj.Count}");



            // Remove the locked assets

            for (int i = jarrayObj.Count - 1; i >= 0; i--)

            {

                var assetID = jarrayObj[i].Value<long>();

                var asset = await _mClientFactory.Client.Entities.GetAsync(assetID);


                bool isLocked = await GetAssetLockStatus(asset);


                if (isLocked)

                {

                    _logger.LogInformation($"Removing locked asset with ID: {assetID}");

                    jarrayObj.RemoveAt(i);

                }

            }


            _logger.LogInformation($"Filtered out locked assets. Remaining assets count: {jarrayObj.Count}");


            config["entity"] = jarrayObj;

            jobDescription.SetPropertyValue("Job.Configuration", config);

            await _mClientFactory.Client.Entities.SaveAsync(jobDescription);


            // resume processing job

            targetEntity.SetPropertyValue("Job.State""Pending");

            await _mClientFactory.Client.Entities.SaveAsync(targetEntity);


            _logger.LogInformation("Execution of H_RemoveLockedAssetsFromDownloadOrder completed.");


            return new OkResult();

        }

        private async Task<bool> GetAssetLockStatus(IEntity asset)

        {

            await asset.LoadPropertiesAsync(new PropertyLoadOption("Dml.IsLocked")).ConfigureAwait(false);

            var isLocked = await asset.GetPropertyValueAsync<bool>("Dml.IsLocked");

            return isLocked;

        }

        protected override IEntityLoadConfiguration GetEntityLoadConfiguration()

        {

            return EntityLoadConfiguration.Default;

        }


        protected override Task<bool> IsValid(IEntity entity)

        {

            return Task.FromResult(true);

        }

    }

}

Conclusion

In the ever-evolving world of digital asset management, it's crucial to have the flexibility to work around limitations in software platforms. Our custom Azure Function solution for filtering locked assets in Sitecore Content Hub is a testament to the power of innovative thinking and the potential of cloud-based serverless computing. So, whether you're a developer seeking a more efficient way to manage assets or a manager looking for a seamless Sitecore Content Hub experience, this custom solution is a game-changer. With Azure Functions at your disposal, the possibilities are truly endless. Happy asset management!