Beyond [Authorize]: Function-Level Permissions in Optimizely
Why basic role checks fall short and how Permissions to Functions unlock audience-based access control
The Problem with Basic Authorization
ASP.NET Core's [Authorize] attribute is the right starting point for access control. It answers a simple question: is this user allowed into the application at all?
For many endpoints, that is enough. But as business rules grow more specific, [Authorize] starts to show its limits:
- It only checks whether the user is authenticated or belongs to a role or policy
- It does not easily support dynamic business rules
- It is not ideal for audience-based or context-based permissions
- It becomes hard to manage when access depends on function-level rules
A familiar scenario
A user may be fully authenticated and even hold a valid CMS role yet still should not be allowed to perform a specific action.
Some features need access for a specific audience, not just a role name baked into code. That gap is where many Optimizely implementations start looking for a better pattern.
The Limitation of Built-In Permission Checks Alone
Optimizely exposes Permissions to Functions through PermissionType and PermissionService. That is a strong foundation: permissions are discoverable in the admin UI and can be assigned to users and roles.
However, relying only on the built-in authorization path has a gap of its own:
- Role and group assignment works well
- Visitor group / audience-based evaluation is not wired into a simple declarative attribute out of the box
- Controllers end up with duplicated authorization logic, or hard-coded role lists that drift from CMS configuration
The result is often a mix of [Authorize(Roles = "...")] strings scattered across controllers, difficult to audit and painful to change when the business audience evolves.
The Solution: AuthorizePermission with Permissions to Functions
Instead of relying only on [Authorize], use a custom authorization attribute backed by Optimizely's Permissions to Functions model.
This approach lets you:
- Control access at the function level
- Evaluate business rules dynamically
- Support audience-based permission checks (users, roles, and visitor groups)
- Keep authorization logic reusable and centralized
Key difference at a glance
| Approach | What it checks | Best for |
| [Authorize] | Authentication, roles, policies | App-level and coarse-grained access |
| Permissions to Functions | CMS-managed audience per permission | Function-level, business-aligned access |
[Authorize] = basic, framework-level access control
CustomAuthorizePermission = custom, function-level permission control
How It Works in Practice
The pattern has three parts:
- Define permission types - discovered by Optimizely and shown under Config → Permissions to functions
- Register virtual roles (optional) - map permissions to virtual roles for broader CMS integration
- Apply a custom filter attribute - evaluate the current user against the permission audience at request time
Step 1: Define permission types
Create a static class decorated with [PermissionTypes]. Each PermissionType represents a function your application exposes, for example, viewing reports, managing settings, or accessing an admin area.
[PermissionTypes]
public static class AppPermissions
{
public const string GroupName = "MyApplication";
static AppPermissions()
{
ViewReports = new PermissionType(GroupName, nameof(ViewReports));
ManageSettings = new PermissionType(GroupName, nameof(ManageSettings));
AccessAdmin = new PermissionType(GroupName, nameof(AccessAdmin));
}
public static PermissionType ViewReports { get; private set; }
public static PermissionType ManageSettings { get; private set; }
public static PermissionType AccessAdmin { get; private set; }
}
Once deployed, these permissions appear in the Optimizely admin UI (CMS --> Settings --> Permissions for Functions). Admins can assign users and roles to each function without redeploying code.
Add localization so friendly names and descriptions appear in Permissions for functions instead of raw type names. Create or update a language XML file (for example Resources/Localization/Views_en.xml):
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<languages>
<language name="English" id="en">
<admin>
<permissiontype>
<groups>
<AppPermissions>
<description>Functions in the application</description>
<permissions>
<ViewReports>Allows users to view reports</ViewReports>
<ManageSettings>Allows users to manage application settings</ManageSettings>
<AccessAdmin>Allows users to access the admin area</AccessAdmin>
</permissions>
</AppPermissions>
</groups>
</permissiontype>
</admin>
</language>
</languages>
Step 2: Register virtual roles (optional)
If you need permissions to participate in Optimizely's role system, for example, in content access rules or visitor group criteria, register a virtual role for each permission:
[InitializableModule]
[ModuleDependency(typeof(EPiServer.Web.InitializationModule))]
public class AppVirtualRoleInitialization : IInitializableModule
{
public void Initialize(InitializationEngine context)
{
var virtualRoleRepository = context.Locate.Advanced
.GetInstance();
virtualRoleRepository.Register("ViewReportsVirtualRole", new PermissionRole
{
Permission = AppPermissions.ViewReports
});
virtualRoleRepository.Register("ManageSettingsVirtualRole", new PermissionRole
{
Permission = AppPermissions.ManageSettings
});
// Register additional permissions as needed
}
public void Uninitialize(InitializationEngine context) { }
}
This step is optional. Skip it if you only need controller-level authorization via a custom attribute.
Step 3: Apply a custom authorization attribute
Create an IAsyncAuthorizationFilter that resolves the permission audience at request time. A typical implementation supports two evaluation paths:
By PermissionType - calls PermissionService.IsPermitted for direct function-level checks against users and roles assigned in the CMS
By PermissionName - resolves visitor groups assigned to the permission via PermissionRepository and evaluates them with IVisitorGroupRole.IsMatch
namespace MyApplication.Attributes
{
using System;
using System.Linq;
using System.Threading.Tasks;
using EPiServer.DataAbstraction;
using EPiServer.Find.Helpers.Text;
using EPiServer.Personalization.VisitorGroups;
using EPiServer.Security;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.DependencyInjection;
using MyApplication.Infrastructure.Security;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
public class AuthorizePermissionAttribute : ActionFilterAttribute, IAsyncAuthorizationFilter
{
public string PermissionName { get; set; }
public PermissionType? PermissionType { get; set; }
public AuthorizePermissionAttribute()
{
}
public AuthorizePermissionAttribute(string name)
{
this.PermissionName = name;
}
public AuthorizePermissionAttribute(string groupName, string name)
{
this.PermissionType = new PermissionType(groupName, name);
}
public async Task OnAuthorizationAsync(AuthorizationFilterContext filterContext)
{
if (this.PermissionType == null && this.PermissionName.IsNullOrEmpty())
{
return;
}
if (this.PermissionName.IsNotNullOrEmpty())
{
await this.AuthorizeByPermissionNameAsync(filterContext);
return;
}
this.AuthorizeByPermissionType(filterContext);
}
private async Task AuthorizeByPermissionNameAsync(AuthorizationFilterContext filterContext)
{
var httpContext = filterContext.HttpContext;
var user = httpContext.User;
var permissionRepository = httpContext.RequestServices.GetRequiredService();
var visitorGroupRoleRepository = httpContext.RequestServices.GetRequiredService();
var permissionType = GetPermissionType(this.PermissionName);
var audiences = await permissionRepository.GetPermissionsAsync(permissionType);
var visitorGroupNames = audiences
.Where(x => x.EntityType == SecurityEntityType.VisitorGroup)
.Select(x => x.Name)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
foreach (var visitorGroupName in visitorGroupNames)
{
var hasRole = visitorGroupRoleRepository.TryGetRole(visitorGroupName, out var visitorGroupRole);
if (!hasRole)
{
continue;
}
var isMatch = visitorGroupRole.IsMatch(user, httpContext);
if (isMatch)
{
return;
}
}
filterContext.Result = new ChallengeResult();
}
private void AuthorizeByPermissionType(AuthorizationFilterContext filterContext)
{
var httpContext = filterContext.HttpContext;
var user = httpContext.User;
var permissionService = httpContext.RequestServices.GetRequiredService();
var isPermitted = permissionService.IsPermitted(user, this.PermissionType);
if (isPermitted)
{
return;
}
var isAuthenticated = user.Identity != null && user.Identity.IsAuthenticated;
filterContext.Result = GetUnauthorizedResult(isAuthenticated);
}
private static IActionResult GetUnauthorizedResult(bool isAuthenticated)
{
if (!isAuthenticated)
{
return new ChallengeResult();
}
return new ForbidResult();
}
private static PermissionType? GetPermissionType(string permissionName)
{
switch (permissionName)
{
case nameof(AppPermissions.ViewReports):
return AppPermissions.ViewReports;
case nameof(AppPermissions.ManageSettings):
return AppPermissions.ManageSettings;
case nameof(AppPermissions.AccessAdmin):
return AppPermissions.AccessAdmin;
default:
return null;
}
}
}
}
Apply the attribute on any controller or action that should be gated by a CMS-managed audience:
// By permission name evaluates visitor groups assigned to the permission
[AuthorizePermission(nameof(AppPermissions.ViewReports))]
public class ReportsController : Controller
{
// Only users matching the ViewReports visitor group audience can access this
}
// By permission type evaluates users and roles via PermissionService
[AuthorizePermission("AppPermissions", nameof(AppPermissions.ManageSettings))]
public class SettingsController : Controller
{
// Only users in the ManageSettings permission audience can access this
}
The permission group name and function name must match what you defined in Step 1. The audience users, roles, and optionally visitor groups is managed entirely in the Optimizely admin UI.
When to Use Each Approach
| Scenario | Recommended approach |
| Public or anonymous endpoints |
[Authorize] |
| Any authenticated CMS user |
[Authorize(Roles = "...")] |
|
Fixed role-based access with no CMS audience |
AuthorizePermission + PermissionTypes |
|
Function-level access managed in Optimizely admin |
AuthorizePermission with permission name resolution |
|
Audience includes visitor groups |
Custom attribute + hybrid auth filter |
Benefits
More flexible than [Authorize]
Roles and policies are static contracts. Permissions to Functions are business contracts managed in the CMS and mapped to real audiences.
Easier to scale
As new features ship, add a PermissionType and an attribute. No more hunting through controllers for magic role strings.
Better alignment with business needs
Marketing, operations, and support teams can reason about who can access Orders or who can change organization context through the admin UI not through source code.
Cleaner separation of concerns
- Authentication establishes identity
- Authorization evaluates function-level audience membership
- Controllers stay focused on business logic
Comments