XiHan Framework 处于预览阶段

查看获取方式
跳转到内容

RBAC 授予与条件 ​

这一页讲权限怎样落到主体身上:授予 PermissionGrant 与检查器的判定顺序、内存与数据库两种授予存储、用 IPermissionFilter 收窄与用 IPermissionGrantSource 扩展授予,以及与存储无关的条件引擎。读完你能把 SimpleApp 的角色授予搬进数据库、给套餐做权限白名单、让角色继承权限,并写出按 IP 与时间判定的条件。阅读前先读授权与权限。

为什么需要 ​

权限码定义了「有哪些事可做」,还得有一张表说「谁能做」。RBAC 把授予挂在用户或角色上,角色来自主体的声明。但真实系统的授予不止一张表:套餐限制要拿走角色里本来有的权限,角色继承与限时委托要加上授予表里没有的权限,风控规则要按 IP、时间、属性再判一次。框架把这些做成可替换的部件:授予存储是 Provider,收窄与来源是两个对称的扩展点,条件引擎独立于存储;检查器按固定顺序把它们串起来,任何一环读不到数据都是失败而不是「不持有」。

授予模型 ​

授予是 PermissionGrant:主体、权限码、可选租户。

成员说明
SubjectGrantSubject,种类只有用户(GrantSubject.ForUser(id),即 user:<id>)与角色(GrantSubject.ForRole(name),即 role:<name>)
Permission权限码;写 *(PermissionGrant.All)匹配任何权限
TenantIdnull 的授予对任何租户生效,租户专属授予只对该租户生效

主体 ID、角色名与权限码都区分大小写。

PermissionChecker 判定「主体是否持有权限」的顺序:

  1. 匿名主体直接不持有,不访问存储。
  2. 查用户本身的授予(全局授予加当前租户的授予)。
  3. 逐个查主体的角色授予,角色取类型为 XiHan:Security:RoleClaim(缺省 role)的声明值。
  4. 存储都没命中时,按登记顺序查询各 IPermissionGrantSource。
  5. 任一步命中即算授予命中,之后按登记顺序调用全部 IPermissionFilter,全部放行才算持有。

存储、来源、过滤器、检查器都返回 Result:GetGrantsAsync 是 Result<IReadOnlyCollection<string>>,AllowsAsync 与 HasPermissionAsync 是 Result<bool>。没有授予是成功的空集合,读不到是失败;检查器遇到第一个失败即原样返回。

读不到不是没有

授予存储、来源或过滤器读不到数据时返回 SECURITY_UNAVAILABLE_001,调用以 503 结束。不要用空集合或 false 代替:那会被判定为不持有,客户端会以为权限被收回,网关也不会重试。自己调用 HasPermissionAsync 的地方同样要处理失败,不能把失败当作不持有。

最小示例 ​

内存实现从配置装载授予。配置里的授予都是全局的,值是逗号分隔的权限码;下面是 SimpleApp 的授予,主体见认证:

json
{
  "XiHan": {
    "Security": {
      "InMemory": {
        "Grants": {
          "role": {
            "editor": "orders.read,orders.write",
            "viewer": "orders.read",
            "event-relay": "events.deliver"
          }
        }
      }
    }
  }
}

alice(角色 editor)调用 orders.create 通过;bob(角色 viewer)调用 orders.create 得到 SECURITY_DENIED_004(HTTP 403);匿名调用得到 SECURITY_DENIED_001(HTTP 401)。

运行时增删授予用 InMemoryPermissionGrantStore.Grant(grant)(返回 Result,容量满时为 SECURITY_UNAVAILABLE_002)与 Revoke(grant)(不存在时返回 false),租户专属授予只能这样添加:store.Grant(new PermissionGrant(GrantSubject.ForUser("bob"), "orders.write", "acme"))。

两种授予存储 ​

Security.InMemorySecurity.Database
装载启动时读配置,运行时 Grant / Revoke 增删数据库表,经 PermissionGrantRecordStore 批量授予、撤销
上限XiHan:Security:InMemory:MaxGrants,缺省 10000无条数上限;缓存 CacheSize 缺省 10000 条
读取进程内查表按(主体,租户)一次查询,结果缓存 CacheTimeToLiveSeconds(缺省 30 秒)
适用开发、测试、授予随配置提交授予由管理后台维护、多实例共享的生产应用

数据库授予:Security.Database ​

Security.Database 只换授予存储:检查器、策略目录与 Authorize 阶段中间件与内存实现是同一套,IPermissionFilter 与 IPermissionGrantSource 照常生效。它只提供 security.authorization@1,不提供认证:与 Security.InMemory 同时装配时顶替它的授权部分,内存实现只剩 ApiKey 认证;认证也可以换成 Security.Jwt 或 Security.OpenIdConnect。

bash
dotnet add package XiHan.Framework.Security.Database --prerelease

本包必须和一个持久化 Provider 一起装配:

csharp
using SqlSugar;
using XiHan.Framework.Hosting.AspNetCore;
using XiHan.Framework.Persistence.SqlSugar;
using XiHan.Framework.Profiles.Standard;
using XiHan.Framework.Security.Database;
using XiHan.Framework.Security.Jwt;

var builder = WebApplication.CreateBuilder(args);

// JWT 认证 + 数据库授予;Standard 组合包里的内存持久化与内存安全都被顶替
builder.AddXiHan(xihan => xihan
    .UseStandardProfile()
    .UseFeature(new SqlSugarPersistenceFeature(static (binding, config) =>
    {
        config.DbType = DbType.PostgreSQL;
        config.ConnectionString = binding.ValueOrThrow().Value;
    }))
    .UseFeature<JwtSecurityFeature>()
    .UseFeature<DatabaseSecurityFeature>());

var app = builder.Build();
app.UseXiHan();
app.Run();

Persistence.EntityFrameworkCore 的应用在自己的 DbContext 里 modelBuilder.Entity<PermissionGrantRecord>() 登记实体并 ApplyPersistenceConventions(Database),四列的联合唯一索引由实体上的 [EntityIndex] 建出,见 Security.Database。

键类型缺省说明
XiHan:Security:Database:CacheSizeint10000缓存条目上限,每个(主体,租户)组合一条;满时整体清空
XiHan:Security:Database:CacheTimeToLiveSecondsdouble30每条缓存的存活时间;0 表示不过期,只靠失效与容量清空

读写分成两个类型,与数据库租户目录同一套做法:DatabasePermissionGrantStore 是单例 IPermissionGrantStore,Authorize 阶段经检查器查它,它在根提供者的新作用域里直接开只读工作单元读仓储(配了从库也读主库),结果进有界的 TTL 缓存;PermissionGrantRecordStore 是作用域服务,管理操作在 Transaction 阶段内用调用方的工作单元批量授予、撤销,逐项返回结果,写入后失效涉及主体的缓存:

csharp
using XiHan.Framework.Security.Abstractions.Permissions;
using XiHan.Framework.Security.Database.Grants;

// 作用域服务,在操作的处理器里使用
public sealed class RoleAdministration(PermissionGrantRecordStore grants)
{
    // 一次授予多项:已存在的返回成功且不重复写
    public ValueTask<IReadOnlyList<PermissionGrantResult>> SetUpEditorAsync(string tenantId, CancellationToken cancellationToken)
        => grants.GrantAsync(
        [
            new PermissionGrant(GrantSubject.ForRole("editor"), "orders.read"),
            new PermissionGrant(GrantSubject.ForRole("editor"), "orders.write", tenantId),
        ], cancellationToken);

    // 不存在的授予逐项返回 SECURITY_NOTFOUND_002
    public ValueTask<IReadOnlyList<PermissionGrantResult>> RevokeWriteAsync(string tenantId, CancellationToken cancellationToken)
        => grants.RevokeAsync([new PermissionGrant(GrantSubject.ForRole("editor"), "orders.write", tenantId)], cancellationToken);
}

ListAsync(subject?, tenantId?, skip, take) 分页列出授予行,每页最多 1000 条。行为要点:

  • 授予是平台级数据:PermissionGrantRecord 不实现 IMultiTenant,不受当前调用租户过滤。全局授予以空字符串(PermissionGrantRecord.GlobalTenantId)落库,契约边界仍以 PermissionGrant.TenantId == null 表示全局;空白租户是编程错误,在访问数据库前抛出 ArgumentException。
  • 授予已存在的返回成功且不重复写;两个调用同时授予同一项时后插入的那一批返回 PERSISTENCE_CONFLICT_003、调用回滚,重试即成功。撤销不存在的授予返回 SECURITY_NOTFOUND_002。
  • 缓存条目按 CacheTimeToLive 过期,条目数达到 CacheSize 时整体清空;写入在事务提交后才对其他调用可见,失效与提交之间的窗口以及其他实例的缓存都靠存活时间收敛。
  • 数据库故障返回 SECURITY_UNAVAILABLE_001,调用以 503 结束,不会落到 403;失败不进缓存,恢复后下一次判定立即重新查库。
  • 表结构由应用管理,InitTables 只用于开发与测试;唯一索引 (SubjectKind, SubjectId, Permission, TenantId) 由 [EntityIndex] 建出。被选为授权能力时同样登记启动核对 PolicyCatalog.EnforceOn。

收窄:IPermissionFilter ​

IPermissionFilter 在授予命中之后再判定一次,返回成功的 false 即视为不持有。它只能拿走权限、不能给出权限,用于套餐白名单、显式拒绝、委托到期这类授予之外的约束。被收窄的权限在权限策略上同样返回 SECURITY_DENIED_004,调用方不需要区分「没授予」与「被收窄」。

csharp
using XiHan.Framework.Kernel.Security;
using XiHan.Framework.Primitives;
using XiHan.Framework.Security.Abstractions.Permissions;

public sealed class EditionPermissionFilter : IPermissionFilter
{
    // 基础版套餐只开放查询;真实应用按租户从套餐表读取并缓存
    private static readonly HashSet<string> _basicEdition = new(StringComparer.Ordinal) { "orders.read" };

    public ValueTask<Result<bool>> AllowsAsync(SecurityContext security, string permission, CancellationToken cancellationToken)
        => ValueTask.FromResult(Result.Success(security.Tenant is null || _basicEdition.Contains(permission)));
}

在特性的 Configure 里按单例登记:context.Services.AddSingleton<IPermissionFilter, EditionPermissionFilter>()。

  • 检查器按登记顺序逐个调用,任一返回 false 即不持有,后面的不再调用;匿名主体与授予未命中时不调用过滤器。
  • 过滤器收到的是具体权限码;主体持有 * 时同样会被收窄。
  • 套餐表读不到时返回失败(Error.Unavailable(SecurityErrors.StoreUnavailable, SecurityMessages.GrantStoreUnavailable)),调用以 503 结束;跳过它等于放行,返回 false 又会变成 403。

授予来源:IPermissionGrantSource ​

IPermissionGrantSource 与过滤器对称:给出主体在授予存储之外的额外授予(权限码或 *),用于角色继承、限时委托这类不落在主体自己授予行上的授予。来源只能加,给出的授予(包括 *)同样经过全部过滤器收窄。

csharp
using XiHan.Framework.Kernel.Security;
using XiHan.Framework.Primitives;
using XiHan.Framework.Security.Abstractions;
using XiHan.Framework.Security.Abstractions.Permissions;

public sealed class ManagerInheritsEditorGrantSource(SecurityOptions options) : IPermissionGrantSource
{
    // manager 角色继承 editor 的权限;继承关系通常来自应用的角色表
    private static readonly IReadOnlyCollection<string> _editorPermissions = ["orders.read", "orders.write"];

    public ValueTask<Result<IReadOnlyCollection<string>>> GetGrantsAsync(SecurityContext security, CancellationToken cancellationToken)
        => ValueTask.FromResult(Result.Success(security.Principal.HasClaim(options.RoleClaim, "manager") ? _editorPermissions : []));
}

同样按单例登记:context.Services.AddSingleton<IPermissionGrantSource, ManagerInheritsEditorGrantSource>()。检查器只在用户与角色的存储授予都没命中时才查询来源,任一来源命中即止;匿名主体不查询。继承关系读不到时返回失败,不要返回空集合。来源拿到整个安全上下文,继承关系一改就生效,不依赖令牌刷新。应用替换 IPermissionChecker 时,必须同样并上全部授予来源、并在授予命中之后施加全部过滤器(规范 RFC-0008)。

条件引擎 ​

条件是访问策略、权限条件与约束规则共用的属性判定,引擎在 XiHan.Framework.Security.Abstractions.Conditions,与存储无关:应用提供属性来源与条件的存储,引擎负责登记、编译、求值。框架没有把条件接进 Authorize 阶段,应用在自己的策略、过滤器或中间件里调用编译器与求值器。

条件集 ConditionSet 由组 ConditionGroup 构成,组由子句 ConditionClause(属性名、运算符、条件值原文、是否取反、标识)构成;组内是「且」,组间是「或」。没有非空组的条件集求值恒为命中,需要拒绝空条件的场景在保存时拦下。运算符与值种类的整数值是持久化契约,不会调整:

运算符(值)可用于
Equals(0)、NotEquals(1)、In(8)、NotIn(9)、IsNull(13)、IsNotNull(14)任何属性
GreaterThan(2)、GreaterThanOrEquals(3)、LessThan(4)、LessThanOrEquals(5)、Between(10)数字、日期时间、日期、时刻
Contains(6)、NotContains(7)多值属性与字符串(单值字符串按子串)
StartsWith(11)、EndsWith(12)字符串
值种类(值)比较与写法
String(0)不区分大小写的序数比较
Number(1)、Boolean(2)十进制;true / false / 1 / 0
DateTime(3)、Date(4)、TimeOfDay(5)ISO 8601,不带偏移的按编译时区的墙钟解释;HH:mm[:ss],区间下限大于上限表示跨零点
IpAddress(6)IPv4 只收四段点分,IPv4 映射的 IPv6 还原为 IPv4;集合可写 CIDR,主机位必须为零

集合写 JSON 数组,或用逗号、全角逗号、换行分隔;区间写 下限,上限、[下限,上限] 或 {"min":下限,"max":上限}。ConditionOperatorRules.AllowedFor(descriptor) 给出某个属性能用的运算符,编辑界面据此列出选项。

属性用 services.AddConditionAttributes(...) 登记,它同时确保 IConditionAttributeRegistry、IConditionCompiler、IConditionEvaluator 三项单例已登记。登记名不区分大小写,可带别名;以 .* 结尾的是一族属性(如 resource.route.*),按最长前缀匹配,精确登记优先;重名与别名冲突在登记表首次解析时抛出。

csharp
using Microsoft.Extensions.DependencyInjection;
using XiHan.Framework.Security.Abstractions.Conditions;

public static class OfficeHoursCondition
{
    public static void Register(IServiceCollection services)
        => services.AddConditionAttributes(
            new ConditionAttributeDescriptor("environment.ip", ConditionValueKind.IpAddress),
            new ConditionAttributeDescriptor("environment.time", ConditionValueKind.TimeOfDay),
            new ConditionAttributeDescriptor("subject.roleCodes", ConditionValueKind.String, IsMultiValued: true));

    // 内网或 auditor 角色,且在 09:00–18:00 之间:组内为且,组间为或
    public static readonly ConditionSet Conditions = new(
    [
        new ConditionGroup([
            new ConditionClause("environment.ip", ConditionOperator.In, "10.0.0.0/8, 192.168.0.0/16"),
            new ConditionClause("environment.time", ConditionOperator.Between, "09:00,18:00"),
        ]),
        new ConditionGroup([
            new ConditionClause("subject.roleCodes", ConditionOperator.Contains, "auditor"),
            new ConditionClause("environment.time", ConditionOperator.Between, "09:00,18:00"),
        ]),
    ]);
}

编译与求值各一步:compiler.Compile(conditions, new ConditionCompileOptions { TimeZone = TimeZoneInfo.Local }) 返回 CompiledConditionSet,Errors 非空就在保存前拒绝(每条错误带 SECURITY_VALIDATION_002 与说明原因的消息键);await evaluator.EvaluateAsync(compiled, source, explain: false, cancellationToken) 返回 ConditionEvaluationResult,看 IsMatched。DictionaryConditionAttributeSource 用于试算与测试;运行时应用实现 IConditionAttributeSource,按规范名返回 ConditionAttributeValue.Of(value)、ConditionAttributeValue.Missing(取不到)或 ConditionAttributeValue.Deferred(暂不可得),MemoizingConditionAttributeSource 让同一次求值里每个属性只取一次。求值只向来源取被引用的属性(CompiledConditionSet.ReferencedAttributes)。

结果子句组条件集
Matched 命中判定成立全部子句命中任一组命中
NotMatched 不命中判定不成立任一子句不命中或无法判定没有组命中也没有组待定
Indeterminate 无法判定属性取不到(SECURITY_NOTFOUND_003)或值不是登记的种类(SECURITY_VALIDATION_003);按不命中计,取反不翻转——
Pending 待定属性暂不可得(SECURITY_UNAVAILABLE_004)没有不命中、有待定没有组命中、有组待定

IsNull / IsNotNull 把取不到、空列表、全是空白的字符串当空,可以取反。多值属性按任一元素判定。编译出错的子句保留在编译结果里、求值恒为无法判定,库里的脏数据不会让整个条件集失效。explain: true 时逐组逐子句给出结果与原因,用于编辑界面的试算。

错误码 ​

错误码含义HTTP
SECURITY_DENIED_004缺少权限,含被 IPermissionFilter 收窄403
SECURITY_NOTFOUND_002要撤销的授予不存在(Security.Database 的逐项结果)—
SECURITY_UNAVAILABLE_001授予存储、授予来源或收窄过滤器读不到数据,不当作不持有503
SECURITY_UNAVAILABLE_002内存授予存储容量已满—
SECURITY_VALIDATION_002 / SECURITY_VALIDATION_003条件无效(属性未登记、运算符与值种类不搭配、值解析不了)/ 属性值不是登记的种类—
SECURITY_NOTFOUND_003 / SECURITY_UNAVAILABLE_004取不到条件属性的值 / 属性暂不可得—
PERSISTENCE_CONFLICT_003两个调用同时写入同一条数据库授予,后者回滚,重试即成功409

条件相关的四个错误码出现在编译结果与说明里,不直接成为调用结果。

常见问题 ​

配置里能写租户专属的授予吗 ​

不能,XiHan:Security:InMemory:Grants 里的授予都是全局的。租户专属授予在内存实现里用 InMemoryPermissionGrantStore.Grant 运行时添加,在数据库实现里经 PermissionGrantRecordStore.GrantAsync 带 tenantId 写入。用户授予、角色授予、来源任一命中即命中,没有「冲突」;要拿走权限用 IPermissionFilter。

条件引擎怎样接进授权 ​

框架没有把它接进 Authorize 阶段,因为属性来源(当前 IP、时间、资源属性)由应用定义。常见做法是写一个自定义 IPolicy 或 IPermissionFilter:从调用来源取 IP(见 IP 归属地)、从主体取角色、从时钟取时间,组成 IConditionAttributeSource,用 IConditionEvaluator 求值,Matched 放行、其他拒绝;Pending 要么当拒绝,要么补齐属性后再求值一次。

条件保存时怎样提示用户写错了 ​

先 IConditionCompiler.Compile,Errors 非空就拒绝保存,每条错误带 SECURITY_VALIDATION_002 与具体原因的消息键,翻译后展示,见全球化和本地化。

相关包与下一步 ​

Released under The MIT License