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 日:修复了少量拼写错误