282 lines
9.4 KiB
TypeScript
282 lines
9.4 KiB
TypeScript
/**
|
||
* 权限判定内核:纯函数 + 预建索引,不碰 uni / pinia / 请求层
|
||
*
|
||
* 语义以服务端为准,逐条对齐 CloudBuilder.Security.Service/Authorization/PermissionContext.cs:
|
||
* - view / create / amend / delete 各自要求对应的 Indc 为 'Y';
|
||
* - none 只要求权限名挂过(后端 CanNone 同款,用于"有授权行就允许,具体动作交给接口判");
|
||
* - 传 dependentId 时只在 PermissionDependents 里找,不回退到 Permissions——回退等于比后端宽;
|
||
* - 不做 '*' 通配、不做大小写宽松匹配:后端是精确字符串比较,前端放宽只会带来
|
||
* "前端放行、后端 403" 的错觉,那正是本层要消除的东西。
|
||
*
|
||
* 与旧 WebVue 的一处行为差异(有意的修复):
|
||
* 旧 hooks/PermissionContext.ts 的 canView 只判断权限名存不存在,漏检 ViewIndc。
|
||
* 于是 view_indc='N'、create_indc='Y' 的授权行会让用户进得来页面,
|
||
* 但页面的列表接口走 Find -> 后端 CanView -> 403,表现为"打开就一片红"。
|
||
* 这里按后端补齐后,进不来就直接跳 403 页,原因写在页面上。
|
||
*
|
||
* 之所以做成纯内核:判定逻辑要能被脚本直接跑起来验证(不依赖设备、不依赖后端),
|
||
* 也方便日后单测;pinia/uni 的取值与响应式都留在 permission-context.ts 那层。
|
||
*/
|
||
import { YesNoPolicy } from "@/data/custom/yesNoPolicy";
|
||
|
||
/** 四种可判定的动作,none 表示"不细分动作,只要求有授权行" */
|
||
export type PermissionAction = "none" | "view" | "create" | "amend" | "delete";
|
||
|
||
/**
|
||
* 权限名入参:单个或数组(数组为 OR,任一命中即通过)。
|
||
* 放在内核里是因为 routes.ts 的 PAGE_PERMISSIONS 也要用同一份定义,
|
||
* 而内核不 import 任何模块,不会形成 routes <-> permission 的循环依赖。
|
||
*/
|
||
export type PermissionNames = string | string[];
|
||
|
||
/**
|
||
* 权限行的结构快照:只声明内核要用的字段,不 import 生成的实体类。
|
||
* 生成器(data/auto/*)改字段名时不该牵连判定逻辑,只要后端返回的 JSON 形状不变就照样工作。
|
||
*/
|
||
export interface PermissionRowLike {
|
||
PermissionName: string;
|
||
ViewIndc?: string;
|
||
CreateIndc?: string;
|
||
AmendIndc?: string;
|
||
DeleteIndc?: string;
|
||
}
|
||
|
||
/** 数据级(dependent)权限行:比功能级多一个 DependentDetailName,如"某个家族/某个机构" */
|
||
export interface DependentRowLike extends PermissionRowLike {
|
||
DependentDetailName: string;
|
||
}
|
||
|
||
/** 内核需要的登录档案权限信息子集,与 SecurityUserPermissionInfo 结构兼容 */
|
||
export interface PermissionInfoLike {
|
||
// 功能级授权行;DependentRowLike 是其超集,故生成实体可直接传入
|
||
Permissions?: PermissionRowLike[] | null;
|
||
// 数据级授权行
|
||
PermissionDependents?: DependentRowLike[] | null;
|
||
}
|
||
|
||
/** 某个权限名上四种动作的合并结果(多组授权会 OR) */
|
||
export interface ActionFlags {
|
||
none: boolean;
|
||
view: boolean;
|
||
create: boolean;
|
||
amend: boolean;
|
||
delete: boolean;
|
||
}
|
||
|
||
/**
|
||
* 预建索引:把旧实现"每次判定都 Array.some 线性扫一遍"换成一次构建、多次 O(1) 查询。
|
||
* 旧版一个页面要调 canView/canCreate/canAmend/canDelete 四次,扫四遍;
|
||
* 相册页权限行可能上百,加上小程序 setData 频率敏感,索引这层不亏。
|
||
*/
|
||
export interface PermissionIndex {
|
||
/** key = PermissionName */
|
||
byName: Map<string, ActionFlags>;
|
||
/** key = dependentKey(permission, dependent) */
|
||
byDependent: Map<string, ActionFlags>;
|
||
}
|
||
|
||
/** 空索引:登录档案缺失时用,配合 context 层的"清单为空即放行"策略 */
|
||
const EMPTY_INDEX: PermissionIndex = {
|
||
byName: new Map(),
|
||
byDependent: new Map(),
|
||
};
|
||
|
||
/** 数据级权限的复合键;用 \u0000 分隔,权限名与明细名都不可能出现控制字符 */
|
||
export function dependentKey(permission: string, dependent: string): string {
|
||
return `${permission}\u0000${dependent}`;
|
||
}
|
||
|
||
function emptyFlags(): ActionFlags {
|
||
return {
|
||
none: false,
|
||
view: false,
|
||
create: false,
|
||
amend: false,
|
||
delete: false,
|
||
};
|
||
}
|
||
|
||
function isYes(value?: string): boolean {
|
||
return value === YesNoPolicy.YES;
|
||
}
|
||
|
||
/** 同一权限名可能被多个用户组授予,合并成 OR:任一组给了某个动作就算有 */
|
||
function merge(
|
||
target: Map<string, ActionFlags>,
|
||
key: string,
|
||
row: PermissionRowLike,
|
||
): void {
|
||
let flags = target.get(key);
|
||
if (!flags) {
|
||
flags = emptyFlags();
|
||
target.set(key, flags);
|
||
}
|
||
flags.none = true;
|
||
flags.view ||= isYes(row.ViewIndc);
|
||
flags.create ||= isYes(row.CreateIndc);
|
||
flags.amend ||= isYes(row.AmendIndc);
|
||
flags.delete ||= isYes(row.DeleteIndc);
|
||
}
|
||
|
||
/**
|
||
* 建索引。传 null/undefined/空清单会得到空索引,不抛异常——
|
||
* 登录档案是从本地缓存水合的,损坏或半截是正常状态,判定层不该让页面崩。
|
||
*/
|
||
export function buildPermissionIndex(
|
||
info?: PermissionInfoLike | null,
|
||
): PermissionIndex {
|
||
if (!info) {
|
||
return EMPTY_INDEX;
|
||
}
|
||
const byName = new Map<string, ActionFlags>();
|
||
const byDependent = new Map<string, ActionFlags>();
|
||
for (const row of info.Permissions ?? []) {
|
||
if (row?.PermissionName) {
|
||
merge(byName, row.PermissionName, row);
|
||
}
|
||
}
|
||
for (const row of info.PermissionDependents ?? []) {
|
||
if (row?.PermissionName && row.DependentDetailName) {
|
||
merge(
|
||
byDependent,
|
||
dependentKey(row.PermissionName, row.DependentDetailName),
|
||
row,
|
||
);
|
||
}
|
||
}
|
||
return { byName, byDependent };
|
||
}
|
||
|
||
function lookup(
|
||
index: PermissionIndex,
|
||
permission: string,
|
||
dependent?: string,
|
||
): ActionFlags | undefined {
|
||
if (dependent) {
|
||
return index.byDependent.get(dependentKey(permission, dependent));
|
||
}
|
||
return index.byName.get(permission);
|
||
}
|
||
|
||
/**
|
||
* 单条判定。
|
||
* @param index 预建索引
|
||
* @param action 要判定的动作
|
||
* @param permission 权限名,须与后端 security_function.permission_name 逐字一致
|
||
* @param dependent 数据级授权明细名;给了它就只查 dependent 集合,与后端一致
|
||
*/
|
||
export function can(
|
||
index: PermissionIndex,
|
||
action: PermissionAction,
|
||
permission: string,
|
||
dependent?: string,
|
||
): boolean {
|
||
const flags = lookup(index, permission, dependent);
|
||
if (!flags) {
|
||
return false;
|
||
}
|
||
return action === "none" ? flags.none : flags[action];
|
||
}
|
||
|
||
/**
|
||
* 批量判定:任一权限名通过即通过(OR)。
|
||
* 一个页面被多个功能权限共享时用得上,例如相册浏览页同时挂在
|
||
* PublishPhotoEnquiry 与 FamilyGroupShare 之下。
|
||
*/
|
||
export function canAny(
|
||
index: PermissionIndex,
|
||
action: PermissionAction,
|
||
permissions: PermissionNames,
|
||
dependent?: string,
|
||
): boolean {
|
||
const list = Array.isArray(permissions) ? permissions : [permissions];
|
||
// 刻意不写成 list.some(name => ...):单参数箭头的括号,prettier 要加、
|
||
// ESLint 要去,改回去就来回拉锯;for-of 在两个工具下都稳定
|
||
for (const name of list) {
|
||
if (name && can(index, action, name, dependent)) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* 后端 [PermissionOperate(name, Operate.X)] 的枚举值,逐字照抄
|
||
* CloudBuilder.Core/Authorization/PermissionOperateAttribute.cs 的 Operate。
|
||
* 用 as const 数组而不是多行字面量联合:联合类型的换行位置 prettier 与
|
||
* operator-linebreak 各执一词,只有这种写法在两个工具下都不被改写
|
||
*/
|
||
export const PERMISSION_OPERATES = [
|
||
"None",
|
||
"Find",
|
||
"Print",
|
||
"Insert",
|
||
"Update",
|
||
"Delete",
|
||
"Submit",
|
||
"Approval",
|
||
] as const;
|
||
|
||
/** 后端 Operate 枚举的字符串取值 */
|
||
export type PermissionOperate = (typeof PERMISSION_OPERATES)[number];
|
||
|
||
/**
|
||
* 按后端的鉴权口径判一个操作:与 PermissionHandler.HandleRequirementAsync 的 switch 一一对应。
|
||
* Find/Print -> view,Insert -> create,Update -> amend,Delete -> delete;
|
||
* 其余(Submit/Approval)走 default 分支——后端把 operate 字符串当成 DependentDetailName
|
||
* 去数据级授权里查"有没有挂过这个操作",所以这里同样是 none + dependent。
|
||
*
|
||
* 用意:前端按钮的可用性判定和接口能不能调,用的是同一份映射,不会出现
|
||
* "提交按钮亮着、请求过去被 403"。新增操作类型时只改这一处。
|
||
*/
|
||
export function canOperate(
|
||
index: PermissionIndex,
|
||
operate: PermissionOperate,
|
||
permission: string,
|
||
): boolean {
|
||
switch (operate) {
|
||
case "None": {
|
||
return can(index, "none", permission);
|
||
}
|
||
case "Find":
|
||
case "Print": {
|
||
return can(index, "view", permission);
|
||
}
|
||
case "Insert": {
|
||
return can(index, "create", permission);
|
||
}
|
||
case "Update": {
|
||
return can(index, "amend", permission);
|
||
}
|
||
case "Delete": {
|
||
return can(index, "delete", permission);
|
||
}
|
||
default: {
|
||
return can(index, "none", permission, operate);
|
||
}
|
||
}
|
||
}
|
||
|
||
/** 取某权限名(或某条数据级授权)的全部动作,供 UI 一次性渲染按钮可用性 */
|
||
export function actionsOf(
|
||
index: PermissionIndex,
|
||
permission: string,
|
||
dependent?: string,
|
||
): ActionFlags {
|
||
return lookup(index, permission, dependent) ?? emptyFlags();
|
||
}
|
||
|
||
/** 已授权的动作清单,调试面板与"我到底有什么权限"排查用 */
|
||
export function grantedPermissions(
|
||
index: PermissionIndex,
|
||
action: PermissionAction,
|
||
): string[] {
|
||
const names: string[] = [];
|
||
index.byName.forEach((flags, name) => {
|
||
if (action === "none" ? flags.none : flags[action]) {
|
||
names.push(name);
|
||
}
|
||
});
|
||
return names;
|
||
}
|