London Dev Meetup Rescheduled! Due to unavoidable reasons, the event has been moved to 21st May. Speakers remain the same—any changes will be communicated. Seats are limited—register here to secure your spot!
London Dev Meetup Rescheduled! Due to unavoidable reasons, the event has been moved to 21st May. Speakers remain the same—any changes will be communicated. Seats are limited—register here to secure your spot!
Hi Kevin,
Just to make sure I've understood what you're looking to do - Most of your users can access all fields of a given piece of content but one group (SEODataEditors) should only have access to the SEO fields. All other fields should be read-only for that group.
It sounds like the inbuilt tab / group permissions wouldn't work for you for a number of reasons so you'll have to do something to modify the field's metadata when editing. I suspect that, for security reasons, you wouldn't be able to make a field editable if the ACL says the user shouldn't have access so I think you might need to grant your SEO editors edit access to all of the content they might need to edit some fields on, then use a metadata extender to deny access to any fields other than the ones they should be able to access.
The way I'd probably go about it would be to create an attribute to add to each of the fields I want to make editable for specific groups like this:
public class PropertyExclusiveAccessAttribute : Attribute
{
public string[] AllowedRoles { get; set; }
public PropertyExclusiveAccessAttribute(string[] allowedRoles)
{
AllowedRoles = allowedRoles;
}
}
Which you could apply to a property like this:
[PropertyExclusiveAccess(["SeoDataEditors"])]
public virtual IList MetaKeywords { get; set; }
I'm going to assume that, for a given content item, a given user should be able to edit all fields as expected unless they are a member of one of the groups named in the attributes on one or more of the properties. The easiest way to do this would be to create a metadata extender which looks through all of the fields on the content item being edited and, if the current user should only have access to certain fields, the rest of the fields are marked as read-only. Here's an example of how that might work:
public class ExclusivePropertyMetadataExtender : IMetadataExtender
{
public void ModifyMetadata(ExtendedMetadata metadata, IEnumerable<Attribute> attributes)
{
if (metadata.Model is IContent)
{
var groups = metadata.Properties.OfType<ExtendedMetadata>()
.Select(x => x.Attributes.OfType<PropertyExclusiveAccessAttribute>().FirstOrDefault()?.AllowedRoles).Where(x => x != null)
.SelectMany(x => x).Where(x => !string.IsNullOrEmpty(x)).Distinct();
//If the user isn't the member of a group with a restriction applied, just render the fields as usual
if (groups == null || !groups.Any(x => EPiServer.Security.PrincipalInfo.CurrentPrincipal.IsInRole(x)))
{
return;
}
//Loop through properties of current content and 1 layer down of nested content (i.e. block properties)
foreach (var extendedMetadata in metadata.Properties.OfType<ContentDataMetadata>().
Union(metadata.Properties.Select(x => x.Properties.OfType<ContentDataMetadata>()).SelectMany(x => x)))
{
//For each property, check whether the user has been specifically allowed to edit this property
//If not, make the property read-only
var attr = extendedMetadata.Attributes.OfType<PropertyExclusiveAccessAttribute>().FirstOrDefault();
if (attr == null || !attr.AllowedRoles.Any(x => EPiServer.Security.PrincipalInfo.CurrentPrincipal.IsInRole(x)))
{
extendedMetadata.IsReadOnly = true;
}
}
}
}
}
Finally, you'll need to register the metadata extender like this:
[InitializableModule]
[ModuleDependency(typeof(EPiServer.Web.InitializationModule))]
public class ExclusivePropertyInitialisation : IInitializableModule
{
public void Initialize(InitializationEngine context)
{
var registry = context.Locate.Advanced.GetInstance<MetadataHandlerRegistry>();
registry.RegisterMetadataHandler(typeof(ContentData), new ExclusivePropertyMetadataExtender());
}
public void Uninitialize(InitializationEngine context)
{
//Add uninitialization logic
}
}
There are a few variations of how this could work but hopefully this gives you a good start.
Hey Paul,
Thank you for your detailed response. It is much appreciated!
I wasn't able to try your suggestions as is. Our project is .NET 6 based, which doesn't allow the IEnumerable attribute group ([, and produces the error: ])])
Feature 'collection expressions' is not available in C# 10.0. Please use language version 12.0 or greater.
To get around this, I modified the attibute's constructor to accept a single role. Not ideal, but got me moving.
public class EditableByRoleAttribute : Attribute
{
public string[] AllowedRoles { get; set; }
public EditableByRoleAttribute(string role)
{
AllowedRoles = new string[] { role };
}
}
The MetadataExtender class you suggested only worked for pages that had at least one property set with the
attribute. As a hack, I implemented the following so that untagged pages were disabled for . [ServiceConfiguration(typeof(IMetadataExtender))]
public class RestrictedFieldMetadataExtender : IMetadataExtender
{
private readonly string _restrictedRole = "SeoDataEditors";
public void ModifyMetadata(ExtendedMetadata metadata, IEnumerable<Attribute> attributes)
{
var currentUser = PrincipalInfo.CurrentPrincipal;
if (!currentUser.IsInRole(_restrictedRole))
return;
foreach (var extendedMetadata in metadata.Properties.OfType<ContentDataMetadata>().
Union(metadata.Properties.Select(x => x.Properties.OfType<ContentDataMetadata>()).SelectMany(x => x)))
{
var attr = extendedMetadata.Attributes.OfType<EditableByRoleAttribute>().FirstOrDefault();
extendedMetadata.IsReadOnly =
(attr == null || !attr.AllowedRoles.Any(x => PrincipalInfo.CurrentPrincipal.IsInRole(x)));
}
}
}
If you have any suggestions on how to handle this in a more generic way, I'd welcome more input.
Thanks,
Kevin
Hi Kevin,
As you spotted, I'd used a collection expression when applying the attribute and, if you're using .net 6, that syntax isn't available to you however you can still pass in an array like this in .net 6:
[PropertyExclusiveAccess(new string[] { "SeoDataEditors", "AnotherRestrictedGroup" })]
To make the metadata extender apply to everything but still keep the flexibility to manage different permissions for different groups on different content types, I'd start by creating an interface to define whether a user should be restricted for the current content type:
public interface IDefineRestrictedGroups
{
public bool IsRestrictedForUser(IPrincipal user);
}
I'd define a default set of restricted groups as a constant alongside my other global constants for the project:
public class Globals
{
// ... other stuff here
public static readonly string[] RestrictedGroupNames = new string[] { "SeoDataEditors" };
}
Then I'd implement the interface in each of my base classes:
public abstract class SitePageData : PageData, IDefineRestrictedGroups
{
// ... other stuff here
public bool IsRestrictedForUser(IPrincipal user)
{
return Globals.RestrictedGroupNames.Any(x => user.IsInRole(x));
}
}
Finally, the metadata extender can be modified to use the interface if available otherwise fallback to using the global restriction list:
public class ExclusivePropertyMetadataExtender : IMetadataExtender
{
public void ModifyMetadata(ExtendedMetadata metadata, IEnumerable attributes)
{
// Check whether content type has been restricted for the current user
// or no restriction has been defined but the user is in one of the globally defined default restricted groups
if ((metadata.Model is IDefineRestrictedGroups restrictedContent && restrictedContent.IsRestrictedForUser(PrincipalInfo.CurrentPrincipal))
|| (metadata.Model is not IDefineRestrictedGroups && metadata.Model is IContent && Globals.RestrictedGroupNames.Any(x => PrincipalInfo.CurrentPrincipal.IsInRole(x))))
{
//Loop through properties of current content and 1 layer down of nested content (i.e. block properties)
foreach (var extendedMetadata in metadata.Properties.OfType().
Union(metadata.Properties.Select(x => x.Properties.OfType()).SelectMany(x => x)))
{
//For each property, check whether the user has been specifically allowed to edit this property
//If not, make the property read-only
var attr = extendedMetadata.Attributes.OfType().FirstOrDefault();
if (attr == null || !attr.AllowedRoles.Any(x => PrincipalInfo.CurrentPrincipal.IsInRole(x)))
{
extendedMetadata.IsReadOnly = true;
}
}
}
}
}
This should ensure all content types get restrictions applied but allows you to override the default restrictions where necessary.
For the sake of completeness, I thought it would be worth just briefly outlining what you could do using the inbuilt permissions model. Out-of-the-box, you can apply permissions to tabs (sometimes referred to as groups). If a user doesn't have permissions to edit the properties in a given tab, the tab is hidden from that user. If you want to allow a group of users to only edit certain fields, you could group those fields into a tab and apply restrictions to all other tabs. In this example, the SeoDataEditors group only needs to be able to edit and publish content which is already there so you could set them up to only have Read, Change and Publish permissions like this:
Then you could set all tabs other than the SEO tab to require "Create" permissions either through the UI like this:
or in code like this:
[GroupDefinitions]
public static class GroupNames
{
[Display(Name = SystemTabNames.Content, Order = 20)]
[RequiredAccess(EPiServer.Security.AccessLevel.Create)]
public const string Content = SystemTabNames.Content;
[Display(Name = "Metadata", Order = 40)]
public const string MetaData = "Metadata";
[Display(Name = SystemTabNames.Settings, Order = 70)]
[RequiredAccess(EPiServer.Security.AccessLevel.Administer)]
public const string Settings = SystemTabNames.Settings;
}
The downsides would be that your SEO editors wouldn't get to see the non-SEO properties plus, because access is granted based on access levels rather than users/groups/roles you are a bit more limited and it gets tricky if you need to support lots of restricted user groups with different intersecting permissions.
Thank you Paul for your assistance on this! On your last suggestion, the downside you mention is a reason this won't work for us. I will be trying the prior option, and will follow up.
Hey all,
I need a way for a group of users to be restricted to editing only SEO related content in the CMS. This group needs privileges to edit and publish only SEO fields, with other fields visible as read-only.
I set up a SeoDataEditors group in Administer Groups and added it to the Root folder using Set Access Rights.
In code, I created an
EditableByRoleAttribute
class for tagging fields to edit, and anEditableByRoleDescriptor
class that overrides the ModifyMetadata method where I setmetadata.IsReadOnly
as needed. Debugging, this code runs as expected. However, there it has no impact in the CMS editor.I also tried adding a MetadataExtender class set up similarly to the EditableByRoleDescriptor.
Bottom line, the Access Rights still drives the editability of all fields regardless of attributes.
Any help would be much appreciated!
-Kevin