Fixing Module Access in Sitecore Content Hub with a Sign-in Script
Auto-Assign Modules to Users on Sign-in in Sitecore Content Hub
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.
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:
| Layer | What It Controls | Inherits from Group? |
|---|---|---|
| Entity Permissions | Read/Write/Delete on specific assets, collections, taxonomies | ✅ Yes |
| Module Access | Which 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.
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:
Architecture
How the Script Works — Step by Step
Context.User gives us the user entity that just signed in. We log the user ID immediately for traceability.
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.
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.
GetRelation("ModuleToUser").GetIds(). Compare against all module IDs. If nothing is missing — log and exit without saving anything.
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.
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
| Scenario | Level | Message |
|---|---|---|
| Script starts | Debug | Sign in for user: {userId} |
| No modules in instance | Info | No enabled modules found. Nothing to assign. |
| Modules found | Debug | Found {N} enabled module(s). |
| User already has all modules | Info | User {userId} already has all {N} module(s). No action taken. |
| Missing modules found | Info | User {userId} is missing {N} module(s): [101, 102]. Assigning now. |
| Assignment success | Info | Successfully assigned module(s) [101, 102] to user {userId}. |
| Any exception | Error | Error: {exception message} |
The ItemCardinalityViolationException — A Real Gotcha
During implementation we hit this error when saving the user entity:
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:
// ❌ 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
Where to Register the Script in Content Hub
[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.
Manage → Scripts and search for CH.User.AssignAllModulesToUser. Confirm it appears with type Sign-in.
Manage → Users → [User] → Modules tab to confirm modules are now assigned. Check script logs for the Info messages.