65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.97/5 (89投票s)

2015年11月18日

CPOL

11分钟阅读

viewsIcon

148907

downloadIcon

4016

本文描述了 C# 中用于高性能基于角色的访问控制列表的实现。

引言

最近,我被要求审查一个大型遗留系统的安全模型并提高其性能。

数据库很大:有超过 10,000 名用户被分配到多个嵌套角色,权限被授予(或拒绝)在超过 120 个独立的、受访问控制的容器上,这些容器包含大约 1300 万条实体记录。仅权限矩阵就包含超过 120 万个数据点。

正如您所知,当用户界面代码和业务逻辑需要定期、反复地处理数百万个数据点以供数千个并发用户使用时,数据结构和算法设计可能会变得出奇地重要。

数据库架构(以及数据模型和对象模型)极其复杂,用于实现基于角色的权限的代码……可以说……效率不高。

更糟糕的是,应用程序权限检查在 Web 服务器和数据库服务器之间产生了大量的通信,这加剧了 UI 的性能问题。

最后,为了增加难度,我的工作有一个限制:我不能以任何方式修改数据库架构——由于我无法控制的外部依赖。我可以修改数据访问层和用户界面层的代码,但我不能做任何架构更改(或数据更改)。

我开发了一个简单、轻量级且(事实证明)闪电般快速的基于角色的访问控制解决方案。我想在这里分享代码,以防其他人发现它对他们解决类似问题有所帮助。

背景

在深入研究代码之前,代码需要一个重要的“基础”假设,应该先进行描述。它看起来很简单,但实际上很微妙:每个权限都可以建模为一个具有三个属性的对象:Principal(主体)、Operation(操作)和Resource(资源)。

主体 (Principals)

Principal(主体)被理解为代表特定用户或用户组的身份。

在基于角色的安全模型中,Principal(主体)等同于角色,这里我们理解一个单独的用户可能被分配到多个角色。我们也理解一个特定个人的身份本身可能就是一个角色。例如,假设我们有三个用户:AliceMad 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 的权限?

这个问题可以分解为两个部分:

  1. 是否存在可由角色 ABC 访问的操作 Y
  2. 如果存在,它是否已在资源 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 类可用于处理“访问已授予”和“访问已拒绝”的权限规则,因此最终,我将 AccessControlItemAccessControlList 重命名为 BaseControlItemBaseControlList,并使用它们创建了一个更功能齐全的实现。

访问控制列表:最终稿

完整的演示可以在本文附带的源代码中找到。最终的 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 万个数据点(PrincipalOperationResource 的所有排列组合)的权限矩阵。原始性能指标包含在本文附带的 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!

改进解决方案

解决方案效果不错,但总有改进的空间。以下是一些想法:

  1. 创建一个 struct 值类型来表示单个访问控制项,以便将 {PrincipalOperationResource} 元组封装到单独的对象中。
  2. 如果系统中的操作是 static(静态)的,则创建一个枚举类型来表示它们,并将 String 数据类型替换到所有对 Operation 变量和参数的引用中。对于系统中的 Resources(资源)也可能如此。
  3. 如果发现生成的代码更容易阅读和维护,请使用 CommaDelimitedStringCollection 数据类型替换 string 数组。我在处理此项目时,惊讶地在 System.Configuration 命名空间中发现了这个类。
  4. StringCollection 中,不要将 Principal(主体)存储为 string 值,而是存储对中央“主列表”中主体的引用,这样您就不会在多个集合中存储相同值的多个副本。
  5. 定义并描述一个干净紧凑的 SQL 数据库架构作为后端。这对于我们不受现有遗留架构约束的新实现会有所帮助。
  6. 该解决方案非常适合引入 领域特定语言 的可能性,这本身就可以成为一个项目(以及一系列文章)!

如果您有其他改进该解决方案的想法,请告知我——我很想听听您的反馈。

关注点

哈希表和字典

泛型 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 日:修复了少量拼写错误
© . All rights reserved.