Fixing Module Access in Sitecore Content Hub with a Sign-in Script

Sitecore Content Hub · C# · Sign-in Scripts

Auto-Assign Modules to Users on Sign-in in Sitecore Content Hub

Content Hub DAM  ·  Scripting  ·  User Management

One of the most common and frustrating issues in a fresh Sitecore Content Hub project is users getting 404 errors after logging in — even though their permissions look correct. The culprit is almost always module access.

This article covers why module access doesn't cascade from user groups, how to diagnose it, and how to fix it permanently using a C# Sign-in Script that auto-assigns all available modules to users on every login.

Real scenario: We assigned the Content module to a user group CH.CMP.Everyone. Users in that group still got 404. Assigning the module directly to individual users fixed it. This led us to investigate and implement the sign-in script solution documented here.

The Problem — Module Access vs Group Permissions

In Content Hub, there are two distinct layers of access control that are easy to confuse:

LayerWhat It ControlsInherits from Group?
Entity PermissionsRead/Write/Delete on specific assets, collections, taxonomies✅ Yes
Module AccessWhich modules (Content, DAM, CMP) are visible in the UI⚠️ Not reliably

Module toggles (Content, DAM, PCM etc.) are user-level privileges in certain Content Hub versions. Even if a user group has module access configured, the individual user's module toggle may still be OFF — causing a 404 on login because the user has no module to land on.

⚠️ Symptom: User belongs to the correct group, group has module access, but user gets 404 after login. Manually enabling the module on the individual user profile fixes it immediately.

Why a Separate Script — Not the Existing Sign-in Script

Most Content Hub projects already have a sign-in script — typically something like CH.User.AssignUserToCHEveryone that adds the user to a default group. We deliberately chose not to add module assignment logic into that existing script. Here's why:

🎯
Single ResponsibilityGroup assignment and module assignment are separate concerns. Mixing them makes the script harder to read, test, and debug.
🛡️
Independent Failure HandlingIf module assignment throws an error, it shouldn't break group assignment — and vice versa. Separate scripts isolate failures cleanly.
🔍
Easier TroubleshootingSeparate scripts give you clear logs per concern — "Group assignment failed" vs "Module assignment failed" — instead of one giant try/catch.
🔄
Safe RollbackIf the new module script has a bug, you can disable just that script without touching the working group assignment logic.

Architecture

User Sign-in SIGN-IN TRIGGER Fires both scripts simultaneously EXISTING SCRIPT CH.User.AssignUser ToCHEveryone NEW SCRIPT CH.User.AssignAll ModulesToUser Adds user to group Assigns modules Both scripts run independently on the same Sign-in trigger event

How the Script Works — Step by Step

1
Get the signed-in user from context Context.User gives us the user entity that just signed in. We log the user ID immediately for traceability.
2
Query all enabled modules in the instance Query for all M.Module entities where IsModuleEnabled == true. This means if a module is later disabled at instance level, it won't be assigned to users either.
3
Load the user's existing module relation Use LoadRelationsAsync(new RelationLoadOption("ModuleToUser")) — loading only this specific relation, not all relations. This avoids the ItemCardinalityViolationException that comes from loading and saving unrelated required relations.
4
Compare and find missing modules Get current module IDs via GetRelation("ModuleToUser").GetIds(). Compare against all module IDs. If nothing is missing — log and exit without saving anything.
5
Add missing modules and save Add missing IDs to the existing list, call SetIds(), then SaveAsync(user). Only the ModuleToUser relation was loaded — so only that relation is affected by the save.

The Script — CH.User.AssignAllModulesToUser

This follows the exact same pattern as the existing CH.User.AssignUserToCHEveryone script — same base class, same constructor, same RunAsync() entry point.

CH.User.AssignAllModulesToUser.csC#
using Custom.ContentHub.Scripts.Deployment.Attribute;
using CH.ContentHub.Scripts.Base;
using Stylelabs.M.Base.Querying;
using Stylelabs.M.Framework.Essentials.LoadOptions;
using Stylelabs.M.Scripting.Types.V1_0.User.SignIn;
using Stylelabs.M.Sdk;
using System;
using System.Linq;
using System.Threading.Tasks;

namespace CH.ContentHub.Scripts.Action.User;

[ContentHubScript("CH.User.AssignAllModulesToUser",
    "Script checks all enabled modules in the instance and assigns any missing ones to the signed-in user")]
public class AssignAllModulesToUser : BaseUserSignInScriptScope
{
    public const string ModuleDefinitionName = "M.Module";
    public const string UserModuleRelationName = "ModuleToUser";
    public const string IsModuleEnabledProperty = "IsModuleEnabled";

    public AssignAllModulesToUser(IUserSignInContext context, IMClient client)
        : base(context, client) { }

    [ContentHubScriptMainMethod]
    public override async Task RunAsync()
    {
        var user = Context.User;
        MClient.Logger.Debug($"[AssignAllModulesToUser] Sign in for user: {user.Id}");

        try
        {
            // STEP 1 — Query all ENABLED modules in the instance
            var moduleQuery = Query.CreateIdsQuery(entities =>
                from e in entities
                where e.DefinitionName == ModuleDefinitionName
                    && e.Property(IsModuleEnabledProperty) == true
                select e);

            var allModuleIds = (await MClient.Querying.QueryIdsAsync(moduleQuery))
                .ToList();

            // No modules found — nothing to do
            if (!allModuleIds.Any())
            {
                MClient.Logger.Info(
                    "[AssignAllModulesToUser] No enabled modules found in instance. Nothing to assign.");
                return;
            }

            MClient.Logger.Debug(
                $"[AssignAllModulesToUser] Found {allModuleIds.Count} enabled module(s).");

            // STEP 2 — Load ONLY the ModuleToUser relation on the user
            // Important: loading only what we need prevents cardinality violations on save
            await user.LoadRelationsAsync(new RelationLoadOption(UserModuleRelationName));
            var assignedModuleIds = user.GetRelation(UserModuleRelationName).GetIds();

            MClient.Logger.Debug(
                $"[AssignAllModulesToUser] User {user.Id} has {assignedModuleIds.Count} module(s) assigned.");

            // STEP 3 — Find what's missing
            var missingModuleIds = allModuleIds
                .Except(assignedModuleIds)
                .ToList();

            // Nothing missing — exit without saving
            if (!missingModuleIds.Any())
            {
                MClient.Logger.Info(
                    $"[AssignAllModulesToUser] User {user.Id} already has all {allModuleIds.Count} module(s). No action taken.");
                return;
            }

            MClient.Logger.Info(
                $"[AssignAllModulesToUser] User {user.Id} is missing {missingModuleIds.Count} module(s): [{string.Join(", ", missingModuleIds)}]. Assigning now.");

            // STEP 4 — Add missing modules and save
            foreach (var moduleId in missingModuleIds)
            {
                assignedModuleIds.Add(moduleId);
            }

            user.GetRelation(UserModuleRelationName).SetIds(assignedModuleIds);
            await MClient.Entities.SaveAsync(user);

            MClient.Logger.Info(
                $"[AssignAllModulesToUser] Successfully assigned module(s) [{string.Join(", ", missingModuleIds)}] to user {user.Id}.");
        }
        catch (Exception ex)
        {
            MClient.Logger.Error($"[AssignAllModulesToUser] Error: {ex.Message}");
        }
    }
}

Log Entries — What You'll See

ScenarioLevelMessage
Script startsDebugSign in for user: {userId}
No modules in instanceInfoNo enabled modules found. Nothing to assign.
Modules foundDebugFound {N} enabled module(s).
User already has all modulesInfoUser {userId} already has all {N} module(s). No action taken.
Missing modules foundInfoUser {userId} is missing {N} module(s): [101, 102]. Assigning now.
Assignment successInfoSuccessfully assigned module(s) [101, 102] to user {userId}.
Any exceptionErrorError: {exception message}

The ItemCardinalityViolationException — A Real Gotcha

During implementation we hit this error when saving the user entity:

ItemCardinalityViolationException: The operation causes a violation of the cardinality of at least one of the relations: entity with id {X} will not be removed from child relation CHMetadataStatusToAsset

This happened because we initially loaded the user with a broad load configuration. When SaveAsync was called, Content Hub tried to reconcile all loaded relations — including ones with a required minimum cardinality (like CHMetadataStatusToAsset which must always have at least one value).

The fix is simple — only load the relation you intend to modify:

Correct approachC#
// ❌ WRONG — loads all relations, SaveAsync tries to reconcile all of them
await user.LoadMembersAsync(
    PropertyLoadOption.None,
    RelationLoadOption.All  // ← causes cardinality violation on save
);

// ✅ CORRECT — only load what you need to modify
await user.LoadRelationsAsync(
    new RelationLoadOption(UserModuleRelationName)  // ← only ModuleToUser
);

What Happens If a Module Is Deleted?

A reasonable question when implementing this — if a module gets deleted from the instance, does this script break?

No. The query filters by IsModuleEnabled == true — deleted modules don't appear in query results. The script always works against the current state of enabled modules in the instance. Any previously assigned deleted module IDs on users are a platform-level concern — Content Hub's own cascade delete handles relation cleanup when entities are removed.

Key Lessons

🔐
Module access ≠ group permissionModule toggles are user-level in some CH versions. Group-level module config alone is not enough — always verify at user level if users report 404 on login.
Load only what you modifyUsing RelationLoadOption with only the specific relation name prevents cardinality violations. Never load all relations if you only intend to modify one.
📋
No-op if nothing to doThe script exits early if the user already has all modules. No unnecessary SaveAsync calls — important for performance on high-traffic sign-in events.
🔀
One script, one concernKeep group assignment and module assignment in separate scripts. Independent failure handling, easier debugging, safer rollback per script.

Where to Register the Script in Content Hub

1
Deploy via your script deployment pipeline The [ContentHubScript] attribute and your project's deployment mechanism (e.g. Your deployment tooling) registers the script as an M.Script entity in Content Hub automatically.
2
Verify in Manage → Scripts After deployment, go to Manage → Scripts and search for CH.User.AssignAllModulesToUser. Confirm it appears with type Sign-in.
3
Test with a real user login Log in as a user who is missing module access. After sign-in, check Manage → Users → [User] → Modules tab to confirm modules are now assigned. Check script logs for the Info messages.
💡 Pro tip: After deploying this script, you don't need to manually fix existing users one by one. Simply ask them to log out and log back in — the script will run on their next sign-in and assign all missing modules automatically.
Sitecore Content Hub Sign-in Script C# Module Access User Management DAM ItemCardinalityViolationException BaseUserSignInScriptScope

Popular Posts

Fetch component-level data in Next.js app using GraphQL - Sitecore Headless Development

Generate a sitemap for Sitecore Headless Next.js app

All Blog Posts - 2024

GraphQL query to fetch the country specific state list - Sitecore Headless Development