C# 中的闪电般快速的访问控制列表






4.97/5 (89投票s)
本文描述了 C# 中用于高性能基于角色的访问控制列表的实现。
引言
最近,我被要求审查一个大型遗留系统的安全模型并提高其性能。
数据库很大:有超过 10,000 名用户被分配到多个嵌套角色,权限被授予(或拒绝)在超过 120 个独立的、受访问控制的容器上,这些容器包含大约 1300 万条实体记录。仅权限矩阵就包含超过 120 万个数据点。
正如您所知,当用户界面代码和业务逻辑需要定期、反复地处理数百万个数据点以供数千个并发用户使用时,数据结构和算法设计可能会变得出奇地重要。
数据库架构(以及数据模型和对象模型)极其复杂,用于实现基于角色的权限的代码……可以说……效率不高。
更糟糕的是,应用程序权限检查在 Web 服务器和数据库服务器之间产生了大量的通信,这加剧了 UI 的性能问题。
最后,为了增加难度,我的工作有一个限制:我不能以任何方式修改数据库架构——由于我无法控制的外部依赖。我可以修改数据访问层和用户界面层的代码,但我不能做任何架构更改(或数据更改)。
我开发了一个简单、轻量级且(事实证明)闪电般快速的基于角色的访问控制解决方案。我想在这里分享代码,以防其他人发现它对他们解决类似问题有所帮助。
背景
在深入研究代码之前,代码需要一个重要的“基础”假设,应该先进行描述。它看起来很简单,但实际上很微妙:每个权限都可以建模为一个具有三个属性的对象:Principal(主体)、Operation(操作)和Resource(资源)。
主体 (Principals)
Principal(主体)被理解为代表特定用户或用户组的身份。
在基于角色的安全模型中,Principal(主体)等同于角色,这里我们理解一个单独的用户可能被分配到多个角色。我们也理解一个特定个人的身份本身可能就是一个角色。例如,假设我们有三个用户:Alice、Mad Hatter(疯帽子)和March Hare(三月兔)。
- 这三个用户都可能被分配到一个名为“Mad Tea Party Attendees”(疯茶会参与者)的角色;
- Mad Hatter(疯帽子)和- March Hare(三月兔)可能被分配到一个名为“- Harmless Lunatics”(无害疯子)的角色;以及
- Alice本人可能被分配到一个名为“- Alice”的角色。
资源
Resource(资源)被理解为可以应用权限的特定实体或容器。
一个好的类比是文件系统中的文件夹,但重要的是要记住,任何数量的事物都可以被建模为安全 Resources(资源)。例如,一个 Resource(资源)可能是
- 一个名为“Top Secret Missions”(绝密任务)的目录;或者
- 一个名为“Western United States”(美国西部)的地理区域;或者
- 用户界面中一个名为“Employee Details”(员工详细信息)的表单;或者
- 在“Mad Tea Party”(疯茶会)活动中的一套茶杯。
操作
Operation(操作)被理解为 Principal(主体)可以在 Resource(资源)上执行的一个特定函数。
最简单和最明显的 Operation(操作)示例是文件或数据库资源上的“Read”(读取)、“Write”(写入)和“Delete”(删除)。然而,我们不局限于这些显而易见的。
- 对名为“Teacup”(茶杯)的Resource(资源)的操作可能包括“Fill”(填充)或“Drop”(放下)或“Drink From”(从……喝);
- 对名为“Employee Details UI View”(员工详细信息 UI 视图)的Resource(资源)的操作可能包括“Add New”(添加新项)、“Edit”(编辑)和“Delete”(删除)。
访问已授予
这三个属性共同为我们提供了构建权限的构建块,这些权限可以作为声明性语句来读取,形式如下:
[Principal] is granted permission to [Operation] on [Resource]
例如
- [Jacob Morley]被授予对- [Large Bank Accounts](大额银行账户)的- [Read](读取)权限
- [Charles Dickens]被授予对- [Oliver Twist Manuscript](《孤星泪》手稿)的- [Revise](修订)权限
- [Peter Rabbit]被拒绝对- [Mr. McGregor's Garden](麦格雷戈先生的花园)的- [Enter](进入)权限
最后一个例子很重要,因为它代表了一个明确 **拒绝** 的权限。
访问已拒绝
一个好的安全模型会默认拒绝所有权限,所以你可能认为 Peter Rabbit 的最后一个例子是多余的,不必要的。然而,在某些情况下,安全模型需要容纳如下规则。
假设您有此权限
[Administrators] are granted permission to [Reset] on [All Servers]
现在假设您的组织中有一个名为 Homer Simpson 的用户,他被分配了两个角色
- 管理员 (Administrators)
- Homer
最后,假设您不希望 Homer 重启服务器(永远,在任何情况下),无论他当前或将来可能被分配到什么角色。在这种情况下,您的安全模型需要能够检查一个覆盖规则,以确保在可能被授予权限的情况下,该权限被拒绝。您需要支持一个如下所示的权限:
[Homer] is denied permission to [Reset] on [All Servers]
视觉上看起来是这样的:

简而言之,您的访问控制列表是所有权限实体的集合。
细节决定成败
考虑到以上所有因素,并且对于一个具有大量嵌套 Principal(主体)、Operation(操作)和 Resource(资源)的应用程序,很容易陷入复杂、臃肿且性能糟糕的代码泥潭。(而且,这里坦白一下,我与其他所有人一样,都曾为解决此类问题开发过糟糕的解决方案。)
然而,本次任务的限制(例如,不允许更改架构!)要求非常专注,这导致了一个非常紧凑的解决方案。这其中肯定蕴含着人生哲理……
铺垫够了。我们来看看代码。
Using the Code
保持简单
性能无疑是此解决方案的最高优先级,紧随其后的是支持客户端代码进行极简调用的要求。权限检查必须看起来像这样:
// Proceed if the user has read access on resource XYZ
if (acl.IsGranted(user.Roles, "Read", "XYZ")) { ... }
创建和加载访问控制列表(ACL)必须足够简单,以便经验较少的开发人员和系统管理员可以重用它。例如:
acl.Grant("Harmless Lunatics", "Attend", "Mad Tea Parties");
为了与应用程序的其余部分保持松耦合,该解决方案需要方法来从 DataTable 或 CSV 文本文件中加载 ACL。
AccessControlListAdapter.Load(acl1, "C:\Files\Permissions.csv");
AccessControlListAdapter.Load(acl2, MySqlDataTable);
该解决方案还必须支持定义规则以强制拒绝本应授予的权限。
// Proceed if the user has permission to drink Irish Coffee; minors are denied regardless
acl.Deny("Minors", "Drink", "Irish Coffee");
if (acl.IsGranted(user.Roles, "Drink", "Irish Coffee")) { ... }
访问控制项和访问控制列表
系统中所有的权限检查都可以归结为一种访问控制问题:
给定用户 X 属于角色 A、B 和 C,他/她是否被授予对资源 Z 执行操作 Y 的权限?
这个问题可以分解为两个部分:
- 是否存在可由角色 A、B或C访问的操作Y?
- 如果存在,它是否已在资源 Z上授予?
这样表达后,就很清楚问题的两个部分都可以使用高性能的哈希表来解决。泛型 Dictionary 类是哈希表的一种特定类型,用于表示键值对的集合,它非常适合回答我们通用的访问控制问题的两个部分。
访问控制项:初稿
这是用于回答我们访问控制问题第一部分的简单 AccessControlItem(访问控制项)类的初稿:
public class AccessControlItem
{
    // Store a collection of principals for each operation
    private readonly Dictionary<string, StringCollection> _principals;
    public AccessControlItem() { _principals = new Dictionary<string, StringCollection>(); }
    public void Grant(string principal, string operation)
    {
        // Get the collection of principals who can perform the operation
        StringCollection value;
        if (!_principals.ContainsKey(operation))
        {
            value = new StringCollection();
            _principals.Add(operation, value);
        }
        else
        {
            value = _principals[operation];
        }
        // Ensure the principal can perform the specified operation
        if (!value.Contains(principal))
            value.Add(principal);
    }
    // Return true if any one of the principals can perform the operation.
    public bool IsGranted(string[] principals, string operation)
    {
        var value = !_principals.ContainsKey(operation) ? null : _principals[operation];
        return value != null && principals.Any(principal => value.Contains(principal));
    }
}
访问控制列表:初稿
这是用于回答我们访问控制问题第二部分的简单 AccessControlList(访问控制列表)类的初稿:
public class AccessControlList : IAccessControlList
{
    // Store an item for each resource
    private readonly Dictionary<string, AccessControlItem> _operations;
    public AccessControlList() { _operations = new Dictionary<string, AccessControlItem>(); }
    public void Grant(string principal, string operation, string resource)
    {
        // Get the item for the resource
        AccessControlItem value;
        if (!_operations.ContainsKey(resource))
        {
            value = new AccessControlItem();
            _operations.Add(resource, value);
        }
        else
        {
            value = _operations[resource];
        }
        // Grant the specified operation to the principal
        value.Grant(principal, operation);
    }
    // Return true if any one of the principals has been granted the operation on the resource
    public bool IsGranted(string[] principals, string operation, string resource)
    {
        var value = !_operations.ContainsKey(resource) ? null : _operations[resource];
        return value != null && value.IsGranted(principals, operation);
    }
}
下面的图示用一个简单的例子说明了这个概念。在这里,您可以看到每个资源都作为查找键,指向操作集合,而每个操作又作为查找键,指向主体集合。

这是一个优雅的解决方案,代码量不多,而且运行速度极快。在我的测试环境中,一个拥有 1,000 个资源、每个资源 10 个操作、每个操作 100 个主体(共计 100 万个数据点)的 ACL,在“IsGranted”(是否已授予)上的任意方法调用,其结果始终在 1 毫秒以内。
当然,上面的代码示例还不能直接用于生产环境。除其他事项外,我们需要包含对 null 键值的检查,并且我们需要处理查找时的区分大小写问题。此外,我们还没有纳入用于检查覆盖性的“访问拒绝”权限规则的功能。
上面简化的 ACL 类可用于处理“访问已授予”和“访问已拒绝”的权限规则,因此最终,我将 AccessControlItem 和 AccessControlList 重命名为 BaseControlItem 和 BaseControlList,并使用它们创建了一个更功能齐全的实现。
访问控制列表:最终稿
完整的演示可以在本文附带的源代码中找到。最终的 ACL 类如下所示:
/// <summary>
/// This class provides a basic implementation for the required ACL interface.
/// </summary>
public class AccessControlList : IAccessControlList
{
    // We can use the same base control class for permissions granted and denied.
    private readonly BaseControlList _granted;
    private readonly BaseControlList _denied;
    /// <summary>
    /// Disallow null ACLs.
    /// </summary>
    public AccessControlList()
    {
        _granted = new BaseControlList();
        _denied = new BaseControlList();
    }
    /// <summary>
    /// Return true if any one of the principals is granted the operation on the resource.
    /// </summary>
    public bool IsGranted(string[] principals, string operation, string resource)
    {
        // Assume every permission is denied by default.
        bool result = false;
        // Check for an overriding denial.
        if (!_denied.IsIncluded(principals, operation, resource))
        {
            // Grant access only if there is an explicit access control rule.
            if (_granted.IsIncluded(principals, operation, resource))
                result = true;
        }
        OnGrantChecked(operation, resource, result);
        return result;
    }
    /// <summary>
    /// Add a permission to the ACL.
    /// </summary>
    public void Grant(string principal, string operation, string resource)
    {
        _granted.Include(principal,operation,resource);
    }
    /// <summary>
    /// Remove a permission from the ACL.
    /// </summary>
    public void Revoke(string principal, string operation, string resource)
    {
        _granted.Exclude(principal, operation, resource);
    }
    /// <summary>
    /// Return true if one of the principals is explicitly denied the operation on the resource.
    /// </summary>
    public bool IsDenied(string[] principals, string operation, string resource)
    {
        return _denied.IsIncluded(principals, operation, resource);
    }
    /// <summary>
    /// Add an overriding permission denial to the ACL.
    /// </summary>
    public void Deny(string principal, string operation, string resource)
    {
        _denied.Include(principal, operation, resource);
    }
    /// <summary>
    /// Explain why the operation is granted or denied, given a collection of principals.
    /// </summary>
    public string Explain(string[] principals, string operation, string resource)
    {
        if (_denied.IsIncluded(principals, operation, resource))
        {
            var included = _denied.FindIncludedPrincipals(principals, operation, resource);
            return string.Format(
                  "Access to operation {1} is explicitly denied to {0} on resource {2}."
                , included, operation, resource);
        }
        if (_granted.IsIncluded(principals, operation, resource))
        {
            var included = _granted.FindIncludedPrincipals(principals, operation, resource);
            return string.Format(
                  "Access to operation {1} is explicitly granted to {0} on resource {2}."
                , included, operation, resource);
        } ;
        return "Permission is not granted to any of the principals specified.";
    }
    #region Events and Delegates
    /// <summary>
    /// An observer might want to monitor ACL lookups and track results for security auditing.
    /// </summary>
    public delegate void GrantCheckHandler(string operation, string resource, bool result);
    public event GrantCheckHandler GrantCheck;
    protected virtual void OnGrantChecked(string operation, string resource, bool result)
    {
        var handler = GrantCheck;
        if (handler != null) handler(operation, resource, result);
    }
    #endregion
}
它到底有多快?
演示源代码包含一个单元测试,用于衡量随机权限检查的性能,即在一个大型访问控制列表类实例上调用 IsGranted 方法。单元测试如下所示:
[Test]
public static void IsGranted_AcceptablePerformance_Success()
{
    var acl = CreateMassiveAcl();
    var results = new StringBuilder();
    results.AppendLine("TestNumber,Principal,Operation,Resource,Granted,Milliseconds");
    
    // Run 5000 tests for permissions granted or denied.
            
    var random = new Random();
    for (var i = 0; i < 5000; i++)
    {
        var randomPrincipal = CreateRandomName(random, "Principal", 1, (int)(1.5 * PrincipalCount));
        var randomOperation = CreateRandomName(random, "Operation", 1, (int)(1.5 * OperationCount));
        var randomResource = CreateRandomName(random, "Resource", 1, (int)(1.5 * ResourceCount));
        var watch = Stopwatch.StartNew();
        bool isGranted = acl.IsIncluded(new[] {randomPrincipal}, randomOperation, randomResource);
        watch.Stop();
        Assert.LessOrEqual(watch.Elapsed.TotalMilliseconds, ExpectedResponseTime);
        results.AppendFormat("{0},{1},{2},{3},{4},{5}"
            , i + 1
            , randomPrincipal
            , randomOperation
            , randomResource
            , isGranted ? "Y" : "N"
            , watch.Elapsed.TotalMilliseconds);
        results.AppendLine();
    }
    // Write the results to a file for analysis.
    const string physicalPath = @"C:\Temp\Performance-Results.csv";
    if (Directory.Exists(Path.GetDirectoryName(physicalPath)))
        File.WriteAllText(physicalPath, results.ToString());
}
我生成了一个包含 100 个主体、10 个操作和 10,000 个资源的访问控制列表,以生成一个包含 1000 万个数据点(Principal、Operation 和 Resource 的所有排列组合)的权限矩阵。原始性能指标包含在本文附带的 Excel 电子表格中:性能结果
以下是摘要:
- ACL 元组数量 = 10,000,000
- 测试迭代次数 = 5,000
- 每次迭代的平均响应时间 = 0.0017 毫秒
- 最佳响应时间 = 0.0003 毫秒
- 最差响应时间 = 0.6748 毫秒
- 标准差 = 0.0105 毫秒
换句话说,平均而言,ACL 类的 IsGranted 方法每秒可处理超过 580,000 次调用,而且我的性能测试显示,当数据集大小加倍时,性能几乎没有下降。
一道闪电的确切速度取决于大气条件,但平均而言,大约是每秒 3700 英里。我让您来判断此解决方案的性能是否可以公平比较。:)
集成解决方案
通过将 ACL 加载到 ApplicationState(应用程序状态)中,几乎消除了 Web 服务器和数据库服务器之间进行权限检查的持续通信。尽管用户账户和用户/角色分配在源系统中经常发生变化,但 Principal(主体)、Operation(操作)和 Resource(资源)几乎完全是 static(静态)的。即便如此,当 Principal(主体)、Operation(操作)和 Resource(资源)实体发生修改时,添加代码重新加载 ACL 和刷新 ApplicationState(应用程序状态)也很容易。
// Added to Global.asax:
public static bool IsAccessGranted(string[] userRoles, string operation, string resource)
{
    var state = HttpContext.Current.Application;
    if (state["AccessControlList"] == null)
        state["AccessControlList"] = GetAclTableFromSourceSystem();
    var acl = (IAccessControlList)state["AccessControlList"];
    return acl.IsGranted(userRoles, operation, resource);
}
private static IAccessControlList GetAclTableFromSourceSystem()
{
    // You'll need to look after this with your own data source.
    return new AccessControlList();
}
// This enables simple, readable permission checks throughout the application:
// if (Global.IsAccessGranted(CurrentUser.Roles, someOperation, someResource)) // proceed!
改进解决方案
解决方案效果不错,但总有改进的空间。以下是一些想法:
- 创建一个 struct值类型来表示单个访问控制项,以便将 {Principal、Operation、Resource} 元组封装到单独的对象中。
- 如果系统中的操作是 static(静态)的,则创建一个枚举类型来表示它们,并将String数据类型替换到所有对Operation变量和参数的引用中。对于系统中的Resources(资源)也可能如此。
- 如果发现生成的代码更容易阅读和维护,请使用 CommaDelimitedStringCollection 数据类型替换 string数组。我在处理此项目时,惊讶地在System.Configuration命名空间中发现了这个类。
- 在 StringCollection中,不要将Principal(主体)存储为string值,而是存储对中央“主列表”中主体的引用,这样您就不会在多个集合中存储相同值的多个副本。
- 定义并描述一个干净紧凑的 SQL 数据库架构作为后端。这对于我们不受现有遗留架构约束的新实现会有所帮助。
- 该解决方案非常适合引入 领域特定语言 的可能性,这本身就可以成为一个项目(以及一系列文章)!
如果您有其他改进该解决方案的想法,请告知我——我很想听听您的反馈。
关注点
哈希表和字典
泛型 Dictionary<TKey,TValue> 类在 .NET Framework 2.0 中引入,作为 Hashtable 类的替代品。据广泛报道,Dictionary 类比 Hashtable 类更快,尽管我也读到过一些认为性能差异微不足道的观点。我还没有(也不一定)运行自己的基准测试来衡量和比较性能结果,但这里有两个关于该主题的文章供您参考:
通过 ASP.NET Web Form 的测试平台
我创建了一个简单的 ASP.NET Web Forms 应用程序,用作测试平台来调试代码。
您可能会发现这个测试平台有助于回答(并解决)您的访问控制列表数据中的问题(以及调试特异性)。如果您有一个大型 ACL,并且用户被分配了许多不同的角色,当一个特定的权限检查返回 true(或 false)时,您可能想确切地知道原因。
例如,如果 Alice 被分配了三个不同的角色(Pilots(飞行员)、Mechanics(机械师)和Ground Crew(地勤人员)),并且 ACL 检查对某个操作和资源返回 true,您可能无法立即知道是这三个角色中的哪一个被授予了该访问权限。测试平台有助于回答这类问题。
快速看一下:

历史
- 2015 年 11 月 18 日:初稿
- 2015 年 11 月 19 日:修复了少量拼写错误;为背景添加了 ERD;附上了更新的演示源代码
- 2015 年 11 月 23 日:修复了少量拼写错误;将性能结果提供为单独的下载文件
- 2015 年 11 月 25 日:修复了少量拼写错误;添加了图表以说明数据结构概念
- 2015 年 12 月 10 日:修复了少量拼写错误


