ow
This commit is contained in:
1 parent
6a8258ac4b
commit
a7b25d49c4
29 files changed
+1960
-649
No files matched your search
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* 权限上下文:把 permission-kernel 接到 pinia 登录档案上
|
||||
*
|
||||
* 数据来源只有一个:useLoginProfileStore().permissionInfo
|
||||
* (登录接口 /Auth/Login 返回的 SecurityUserPermissionInfo,已在 store 的 persist.paths 里持久化)。
|
||||
* 不再另开一份权限缓存,避免和档案不同源。
|
||||
*
|
||||
* 两套判定策略,故意的:
|
||||
* 1) 路由级(canVisitPage)走 fail-open——本地档案缺失、权限清单为空时放行。
|
||||
* 理由与旧项目一致:档案是从本地缓存水合的,一旦过期或被写坏成空档案,
|
||||
* 严格模式会把所有人全挡在 403 外面,而真正的安全边界在后端(每个接口都有 [PermissionOperate])。
|
||||
* 2) 按钮级(usePagePermission / usePermission)走 fail-closed——没有授权证据就不显示。
|
||||
* 多挡一个按钮只是少个入口,误放一个按钮却会让人点了才收到 403。
|
||||
*/
|
||||
import type { ComputedRef } from "vue";
|
||||
import type {
|
||||
PermissionAction,
|
||||
PermissionIndex,
|
||||
PermissionInfoLike,
|
||||
PermissionNames,
|
||||
PermissionOperate,
|
||||
} from "@/permission/permission-kernel";
|
||||
import type { PageName } from "@/router/routes";
|
||||
import { computed } from "vue";
|
||||
import {
|
||||
actionsOf,
|
||||
buildPermissionIndex,
|
||||
can,
|
||||
canAny,
|
||||
canOperate,
|
||||
grantedPermissions,
|
||||
} from "@/permission/permission-kernel";
|
||||
import { PAGE_PERMISSIONS, resolvePageName } from "@/router/routes";
|
||||
import { useLoginProfileStore } from "@/stores";
|
||||
|
||||
/** 权限名支持数组:任一通过即通过(一个页面挂在多个功能权限下);定义在内核,此处转出便于页面直接使用 */
|
||||
export type { PermissionNames } from "@/permission/permission-kernel";
|
||||
|
||||
/**
|
||||
* 权限快照(与旧 WebVue 的 hooks/PermissionContext.ts 同名同 API,
|
||||
* 便于旧页面代码继续移植;区别是这里构造时建一次索引,四个方法都是 O(1))
|
||||
*/
|
||||
export class PermissionContext {
|
||||
private readonly index: PermissionIndex;
|
||||
|
||||
constructor(info?: PermissionInfoLike | null) {
|
||||
this.index = buildPermissionIndex(info);
|
||||
}
|
||||
|
||||
canNone(permission: PermissionNames): boolean {
|
||||
return canAny(this.index, "none", permission);
|
||||
}
|
||||
|
||||
canView(permission: PermissionNames, dependentId?: string): boolean {
|
||||
return canAny(this.index, "view", permission, dependentId);
|
||||
}
|
||||
|
||||
canCreate(permission: PermissionNames, dependentId?: string): boolean {
|
||||
return canAny(this.index, "create", permission, dependentId);
|
||||
}
|
||||
|
||||
canAmend(permission: PermissionNames, dependentId?: string): boolean {
|
||||
return canAny(this.index, "amend", permission, dependentId);
|
||||
}
|
||||
|
||||
canDelete(permission: PermissionNames, dependentId?: string): boolean {
|
||||
return canAny(this.index, "delete", permission, dependentId);
|
||||
}
|
||||
|
||||
/** 某权限(或某条数据级授权)上四种动作的完整结果,一次取全给整页按钮用 */
|
||||
flags(permission: string, dependentId?: string) {
|
||||
return actionsOf(this.index, permission, dependentId);
|
||||
}
|
||||
|
||||
/** 后端 [PermissionOperate(name, Operate.X)] 同款判定,含 Submit/Approval */
|
||||
canOperate(operate: PermissionOperate, permission: string): boolean {
|
||||
return canOperate(this.index, operate, permission);
|
||||
}
|
||||
}
|
||||
|
||||
// 共享索引:全站建一次,档案变化时由 computed 自动失效重建。
|
||||
// 旧实现是每次 usePermissionContext() 都 new 一个上下文、每个判定都线性扫数组;
|
||||
// 相册这类页面一屏十几个按钮各判各的,重建成本会落到渲染路径上。
|
||||
let sharedIndex: ComputedRef<PermissionIndex> | null = null;
|
||||
|
||||
function permissionIndex(): ComputedRef<PermissionIndex> {
|
||||
if (!sharedIndex) {
|
||||
const store = useLoginProfileStore();
|
||||
sharedIndex = computed(() => buildPermissionIndex(store.permissionInfo));
|
||||
}
|
||||
return sharedIndex;
|
||||
}
|
||||
|
||||
/** 取当前档案的索引;在渲染/判定期间读 .value,等于把"档案变→重新判定"交给 Vue 依赖追踪 */
|
||||
function currentIndex(): PermissionIndex {
|
||||
return permissionIndex().value;
|
||||
}
|
||||
|
||||
/** 当前登录用户的权限快照;档案为空时得到空索引(判定一律 false) */
|
||||
export function usePermissionContext(): PermissionContext {
|
||||
const store = useLoginProfileStore();
|
||||
return new PermissionContext(store.permissionInfo);
|
||||
}
|
||||
|
||||
/** 组合式判定:函数形式,读的是共享索引,随档案变化自动生效 */
|
||||
export function usePermission() {
|
||||
return {
|
||||
canNone: (permission: PermissionNames) =>
|
||||
canAny(currentIndex(), "none", permission),
|
||||
canView: (permission: PermissionNames, dependentId?: string) =>
|
||||
canAny(currentIndex(), "view", permission, dependentId),
|
||||
canCreate: (permission: PermissionNames, dependentId?: string) =>
|
||||
canAny(currentIndex(), "create", permission, dependentId),
|
||||
canAmend: (permission: PermissionNames, dependentId?: string) =>
|
||||
canAny(currentIndex(), "amend", permission, dependentId),
|
||||
canDelete: (permission: PermissionNames, dependentId?: string) =>
|
||||
canAny(currentIndex(), "delete", permission, dependentId),
|
||||
/** 数据级或动作型判定的统一入口,如 can("view", "FamilyGroupShare", familyId) */
|
||||
can: (action: PermissionAction, permission: string, dependentId?: string) =>
|
||||
can(currentIndex(), action, permission, dependentId),
|
||||
/** 与后端接口鉴权同口径的操作判定,如 canOperate("Submit", "PublishPhotoEnquiry") */
|
||||
canOperate: (operate: PermissionOperate, permission: string) =>
|
||||
canOperate(currentIndex(), operate, permission),
|
||||
/** 已授权的权限名清单,调试与"我有什么权限"排查用 */
|
||||
granted: (action: PermissionAction = "none") =>
|
||||
grantedPermissions(currentIndex(), action),
|
||||
};
|
||||
}
|
||||
|
||||
/** 页面绑定的权限名;未登记表示该页不做权限校验 */
|
||||
export function pagePermission(name: PageName): PermissionNames | undefined {
|
||||
return PAGE_PERMISSIONS[name];
|
||||
}
|
||||
|
||||
/** 当前页面名:按页面栈栈顶的真实路径反查(拿不到返回 null) */
|
||||
export function currentPageName(): PageName | null {
|
||||
const pages = getCurrentPages();
|
||||
const current = pages[pages.length - 1];
|
||||
return current ? resolvePageName(`/${current.route}`) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面级四把锁,模板里直接 v-if="perm.canDelete" 用。
|
||||
* @param name 页面名;省略时按页面栈推断,但在 onLoad 早期栈可能还没压上当前页,
|
||||
* 所以能显式传就显式传,省得依赖一个时机敏感的行为
|
||||
*/
|
||||
export function usePagePermission(name?: PageName) {
|
||||
const target = name ?? currentPageName();
|
||||
const permission = target ? pagePermission(target) : undefined;
|
||||
// 没绑定权限名的页面(欢迎页、错误页等)四个锁全开:它不属于权限体系管的事
|
||||
const pass = () => !permission;
|
||||
const flags = computed(() => {
|
||||
if (!permission) {
|
||||
return {
|
||||
none: true,
|
||||
view: true,
|
||||
create: true,
|
||||
amend: true,
|
||||
delete: true,
|
||||
};
|
||||
}
|
||||
const list = Array.isArray(permission) ? permission : [permission];
|
||||
// 多个权限名时取并集:任一给了该动作即算有
|
||||
const merged = {
|
||||
none: false,
|
||||
view: false,
|
||||
create: false,
|
||||
amend: false,
|
||||
delete: false,
|
||||
};
|
||||
for (const item of list) {
|
||||
const one = actionsOf(currentIndex(), item);
|
||||
merged.none ||= one.none;
|
||||
merged.view ||= one.view;
|
||||
merged.create ||= one.create;
|
||||
merged.amend ||= one.amend;
|
||||
merged.delete ||= one.delete;
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
return {
|
||||
pageName: target ?? null,
|
||||
permission: permission ?? null,
|
||||
/** 一次拿全五种结果,模板里频繁判定时比四个 computed 少读四次索引 */
|
||||
flags,
|
||||
canNone: computed(() => pass() || flags.value.none),
|
||||
canView: computed(() => pass() || flags.value.view),
|
||||
canCreate: computed(() => pass() || flags.value.create),
|
||||
canAmend: computed(() => pass() || flags.value.amend),
|
||||
canDelete: computed(() => pass() || flags.value.delete),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录档案里的权限清单是否不可信(缺失/空)。
|
||||
* 空清单既可能是"这人真没权限",也可能是"本地档案被写坏或还没水合",
|
||||
* 前端分不出这两种情况,所以只在路由级选择放行,把裁决权交回后端。
|
||||
*/
|
||||
function permissionInfoUnusable(): boolean {
|
||||
const info = useLoginProfileStore().permissionInfo;
|
||||
return !info?.Permissions?.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 路由级判定:能不能进这个页面。
|
||||
* 顺序上必须在登录校验之后(未登录该给 401 而不是 403),由 nav-guard 保证。
|
||||
*/
|
||||
export function canVisitPage(name: PageName): boolean {
|
||||
const permission = pagePermission(name);
|
||||
if (!permission) {
|
||||
return true;
|
||||
}
|
||||
if (permissionInfoUnusable()) {
|
||||
return true;
|
||||
}
|
||||
return canAny(currentIndex(), "view", permission);
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
/**
|
||||
* 权限内核的可执行校验(不引测试框架,纯 TS + 断言函数)
|
||||
*
|
||||
* 跑法(PowerShell,在项目根目录):
|
||||
* & "C:\Program Files\nodejs\node.exe" ".\node_modules\esbuild\bin\esbuild" `
|
||||
* src\permission\permission-kernel.spec.ts --bundle --platform=node --format=cjs `
|
||||
* --alias:@=./src --outfile="$env:TEMP\permission-kernel.spec.cjs" --log-level=warning
|
||||
* & "C:\Program Files\nodejs\node.exe" "$env:TEMP\permission-kernel.spec.cjs"
|
||||
*
|
||||
* 为什么要它:判定语义的正确性只能靠"跑一遍"来钉住,尤其 canView 是否检 ViewIndc
|
||||
* 这条——它是本次移植相对旧 WebVue 唯一的行为变更,靠读代码很容易自我说服"应该没问题"。
|
||||
* 项目目前还没接测试运行器,所以先写成"能被 esbuild 打包直接用 node 跑"的形态:
|
||||
* 将来引入 vitest,只需把 check() 换成 expect(),用例本身不用动。
|
||||
*
|
||||
* 约定:所有用例都对照后端 CloudBuilder.Security.Service/Authorization/PermissionContext.cs
|
||||
* 的既有行为写,断言的是"前端与后端一致",不是"前端自己的偏好"。
|
||||
*/
|
||||
import type {
|
||||
DependentRowLike,
|
||||
PermissionInfoLike,
|
||||
PermissionRowLike,
|
||||
} from "./permission-kernel";
|
||||
import {
|
||||
actionsOf,
|
||||
buildPermissionIndex,
|
||||
can,
|
||||
canAny,
|
||||
canOperate,
|
||||
grantedPermissions,
|
||||
} from "./permission-kernel";
|
||||
|
||||
let failed = 0;
|
||||
let total = 0;
|
||||
|
||||
/** 断言:失败不中断,好让一次跑完能看清全部问题 */
|
||||
function check(label: string, actual: unknown, expected: unknown): void {
|
||||
total++;
|
||||
if (actual !== expected) {
|
||||
failed++;
|
||||
// 本项目的 no-console 只放行 warn/error(页面代码里不该打印),
|
||||
// 这是个只在 node 下跑的校验脚本,就沿用放行的那一个
|
||||
console.warn(
|
||||
` FAIL ${label} => expected ${String(expected)}, got ${String(actual)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function row(
|
||||
permission: string,
|
||||
flags: Partial<Record<"amend" | "create" | "delete" | "view", string>>,
|
||||
): PermissionRowLike {
|
||||
return {
|
||||
PermissionName: permission,
|
||||
ViewIndc: flags.view,
|
||||
CreateIndc: flags.create,
|
||||
AmendIndc: flags.amend,
|
||||
DeleteIndc: flags.delete,
|
||||
};
|
||||
}
|
||||
|
||||
function depRow(
|
||||
permission: string,
|
||||
dependent: string,
|
||||
flags: Partial<Record<"amend" | "create" | "delete" | "view", string>>,
|
||||
): DependentRowLike {
|
||||
return { ...row(permission, flags), DependentDetailName: dependent };
|
||||
}
|
||||
|
||||
function info(
|
||||
permissions: PermissionRowLike[] = [],
|
||||
dependents: DependentRowLike[] = [],
|
||||
): PermissionInfoLike {
|
||||
return { Permissions: permissions, PermissionDependents: dependents };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 1. view 必须检 ViewIndc
|
||||
{
|
||||
// view_indc='N' 而 create_indc='Y':后端 CanView 为 false。
|
||||
// 旧前端只看"权限名在不在",会误放行,页面进来后列表接口一片 403。
|
||||
const index = buildPermissionIndex(
|
||||
info([row("ActorEnquiry", { view: "N", create: "Y" })]),
|
||||
);
|
||||
check(
|
||||
"view=N 时 canView 为 false(修复旧前端漏检)",
|
||||
can(index, "view", "ActorEnquiry"),
|
||||
false,
|
||||
);
|
||||
check(
|
||||
"view=N 时 canNone 仍为 true(只要求挂过权限)",
|
||||
can(index, "none", "ActorEnquiry"),
|
||||
true,
|
||||
);
|
||||
check(
|
||||
"create=Y 时 canCreate 为 true",
|
||||
can(index, "create", "ActorEnquiry"),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 2. 四种动作各自独立
|
||||
{
|
||||
const index = buildPermissionIndex(
|
||||
info([row("DmsFileEnquiry", { view: "Y" })]),
|
||||
);
|
||||
check("只给 view:view 通过", can(index, "view", "DmsFileEnquiry"), true);
|
||||
check(
|
||||
"只给 view:create 不通过",
|
||||
can(index, "create", "DmsFileEnquiry"),
|
||||
false,
|
||||
);
|
||||
check(
|
||||
"只给 view:amend 不通过",
|
||||
can(index, "amend", "DmsFileEnquiry"),
|
||||
false,
|
||||
);
|
||||
check(
|
||||
"只给 view:delete 不通过",
|
||||
can(index, "delete", "DmsFileEnquiry"),
|
||||
false,
|
||||
);
|
||||
|
||||
const full = buildPermissionIndex(
|
||||
info([
|
||||
row("DmsFileEnquiry", {
|
||||
view: "Y",
|
||||
create: "Y",
|
||||
amend: "Y",
|
||||
delete: "Y",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
check("四标全 Y:delete 通过", can(full, "delete", "DmsFileEnquiry"), true);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 3. 多用户组授权做 OR 合并
|
||||
{
|
||||
// 同一权限名由两个用户组分别授予 view 与 delete,合并后两者都应有
|
||||
const index = buildPermissionIndex(
|
||||
info([
|
||||
row("FamilyGroupEnquiry", { view: "Y" }),
|
||||
row("FamilyGroupEnquiry", { delete: "Y" }),
|
||||
]),
|
||||
);
|
||||
check("多组合并:view 命中", can(index, "view", "FamilyGroupEnquiry"), true);
|
||||
check(
|
||||
"多组合并:delete 命中",
|
||||
can(index, "delete", "FamilyGroupEnquiry"),
|
||||
true,
|
||||
);
|
||||
check(
|
||||
"多组合并:amend 仍未命中",
|
||||
can(index, "amend", "FamilyGroupEnquiry"),
|
||||
false,
|
||||
);
|
||||
const flags = actionsOf(index, "FamilyGroupEnquiry");
|
||||
check(
|
||||
"actionsOf 一次取全",
|
||||
`${flags.view}/${flags.create}/${flags.amend}/${flags.delete}`,
|
||||
"true/false/false/true",
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 4. 数据级只查 dependents,不回退
|
||||
{
|
||||
const index = buildPermissionIndex(
|
||||
info(
|
||||
[row("PublishPhotoEnquiry", { view: "Y" })],
|
||||
[depRow("PublishPhotoEnquiry", "F1001", { view: "N" })],
|
||||
),
|
||||
);
|
||||
check("功能级命中", can(index, "view", "PublishPhotoEnquiry"), true);
|
||||
// 后端 CanView(permission, dependent) 只查 PermissionDependents;
|
||||
// 前端若"数据级没查到就退回功能级",就比后端宽,会出现前端放行后端 403
|
||||
check(
|
||||
"数据级 N:不回退到功能级",
|
||||
can(index, "view", "PublishPhotoEnquiry", "F1001"),
|
||||
false,
|
||||
);
|
||||
check(
|
||||
"数据级整条不存在:false",
|
||||
can(index, "view", "PublishPhotoEnquiry", "F9999"),
|
||||
false,
|
||||
);
|
||||
check(
|
||||
"数据级 Y:命中",
|
||||
can(
|
||||
buildPermissionIndex(
|
||||
info([], [depRow("PublishPhotoEnquiry", "F1", { view: "Y" })]),
|
||||
),
|
||||
"view",
|
||||
"PublishPhotoEnquiry",
|
||||
"F1",
|
||||
),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 5. 严格比较(与后端一致)
|
||||
{
|
||||
const index = buildPermissionIndex(
|
||||
info([
|
||||
row("SecurityGroupEnquiry", { view: "y" }),
|
||||
row(" security ", { view: "Y" }),
|
||||
]),
|
||||
);
|
||||
check(
|
||||
"小写 y 不算 YES(后端按 'Y' 精确比较)",
|
||||
can(index, "view", "SecurityGroupEnquiry"),
|
||||
false,
|
||||
);
|
||||
check("权限名区分大小写", can(index, "view", "securitygroupenquiry"), false);
|
||||
check("权限名不做 trim", can(index, "view", "security"), false);
|
||||
check("原样存在的名字可命中", can(index, "view", " security "), true);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 6. 档案缺失或损坏
|
||||
{
|
||||
const broken: PermissionInfoLike[] = [
|
||||
{},
|
||||
{ Permissions: null },
|
||||
{ Permissions: undefined, PermissionDependents: undefined },
|
||||
{ Permissions: [] },
|
||||
{ Permissions: [null as unknown as PermissionRowLike] },
|
||||
{ Permissions: [{ PermissionName: "" } as PermissionRowLike] },
|
||||
{
|
||||
PermissionDependents: [
|
||||
{ PermissionName: "A", DependentDetailName: "" } as DependentRowLike,
|
||||
],
|
||||
},
|
||||
];
|
||||
broken.forEach((item, i) => {
|
||||
const index = buildPermissionIndex(item);
|
||||
check(
|
||||
`损坏档案[${i}]:不抛异常且判定为 false`,
|
||||
can(index, "view", "A"),
|
||||
false,
|
||||
);
|
||||
check(`损坏档案[${i}]:none 也为 false`, can(index, "none", "A"), false);
|
||||
});
|
||||
check(
|
||||
"传 null 得到空索引",
|
||||
can(buildPermissionIndex(null), "none", "A"),
|
||||
false,
|
||||
);
|
||||
check(
|
||||
"不传参得到空索引",
|
||||
grantedPermissions(buildPermissionIndex(), "none").length,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 7. canAny 批量判定
|
||||
{
|
||||
const index = buildPermissionIndex(
|
||||
info([row("PreviewAudio", { view: "Y" })]),
|
||||
);
|
||||
check("数组任一命中", canAny(index, "view", ["Nope", "PreviewAudio"]), true);
|
||||
check("数组全不命中", canAny(index, "view", ["Nope", "Nothing"]), false);
|
||||
check("空数组为 false(而非 undefined)", canAny(index, "view", []), false);
|
||||
check(
|
||||
"数组里的空串被跳过",
|
||||
canAny(index, "view", ["", "PreviewAudio"]),
|
||||
true,
|
||||
);
|
||||
check("单串仍可用", canAny(index, "view", "PreviewAudio"), true);
|
||||
check(
|
||||
"批量判定带 dependent",
|
||||
canAny(index, "view", ["PreviewAudio"], "F1"),
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 8. 未登记动作与未知权限
|
||||
{
|
||||
const index = buildPermissionIndex(
|
||||
info([row("ActorEnquiry", { view: "Y" })]),
|
||||
);
|
||||
check("未授予的权限名:false", can(index, "view", "NeverGranted"), false);
|
||||
const flags = actionsOf(index, "NeverGranted");
|
||||
check(
|
||||
"未授予权限的 actionsOf 返回全 false 而非 undefined",
|
||||
`${flags.none}/${flags.view}`,
|
||||
"false/false",
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 9. 已授权清单
|
||||
{
|
||||
const index = buildPermissionIndex(
|
||||
info([
|
||||
row("A_Enquiry", { view: "Y" }),
|
||||
row("B_Enquiry", { view: "N", create: "Y" }),
|
||||
row("C_Enquiry", { view: "Y", delete: "Y" }),
|
||||
]),
|
||||
);
|
||||
check(
|
||||
"none 清单含全部挂过的名字",
|
||||
grantedPermissions(index, "none").join(","),
|
||||
"A_Enquiry,B_Enquiry,C_Enquiry",
|
||||
);
|
||||
check(
|
||||
"view 清单只含 Y",
|
||||
grantedPermissions(index, "view").join(","),
|
||||
"A_Enquiry,C_Enquiry",
|
||||
);
|
||||
check(
|
||||
"create 清单",
|
||||
grantedPermissions(index, "create").join(","),
|
||||
"B_Enquiry",
|
||||
);
|
||||
check(
|
||||
"delete 清单",
|
||||
grantedPermissions(index, "delete").join(","),
|
||||
"C_Enquiry",
|
||||
);
|
||||
check("amend 清单为空", grantedPermissions(index, "amend").length, 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 10. 操作型判定(对齐后端 PermissionHandler 的 switch)
|
||||
{
|
||||
const index = buildPermissionIndex(
|
||||
info(
|
||||
[
|
||||
row("PublishPhotoEnquiry", {
|
||||
view: "Y",
|
||||
create: "Y",
|
||||
amend: "Y",
|
||||
delete: "Y",
|
||||
}),
|
||||
],
|
||||
// Submit/Approval 这类操作在后端是"数据级授权行",靠 DependentDetailName 命中
|
||||
[depRow("PublishPhotoEnquiry", "Submit", { view: "N" })],
|
||||
),
|
||||
);
|
||||
check("Find -> view", canOperate(index, "Find", "PublishPhotoEnquiry"), true);
|
||||
check(
|
||||
"Print -> view",
|
||||
canOperate(index, "Print", "PublishPhotoEnquiry"),
|
||||
true,
|
||||
);
|
||||
check(
|
||||
"Insert -> create",
|
||||
canOperate(index, "Insert", "PublishPhotoEnquiry"),
|
||||
true,
|
||||
);
|
||||
check(
|
||||
"Update -> amend",
|
||||
canOperate(index, "Update", "PublishPhotoEnquiry"),
|
||||
true,
|
||||
);
|
||||
check(
|
||||
"Delete -> delete",
|
||||
canOperate(index, "Delete", "PublishPhotoEnquiry"),
|
||||
true,
|
||||
);
|
||||
check(
|
||||
"None 只要求挂过",
|
||||
canOperate(index, "None", "PublishPhotoEnquiry"),
|
||||
true,
|
||||
);
|
||||
check(
|
||||
"Submit 走数据级,命中",
|
||||
canOperate(index, "Submit", "PublishPhotoEnquiry"),
|
||||
true,
|
||||
);
|
||||
check(
|
||||
"Approval 未挂 → false(即使四个 Indc 全 Y)",
|
||||
canOperate(index, "Approval", "PublishPhotoEnquiry"),
|
||||
false,
|
||||
);
|
||||
|
||||
// view_indc='N' 时 Find 必须为 false:这条正是旧前端会误判成 true 的场景
|
||||
const noView = buildPermissionIndex(
|
||||
info([row("ActorEnquiry", { view: "N", create: "Y" })]),
|
||||
);
|
||||
check(
|
||||
"view=N 时 Find 为 false",
|
||||
canOperate(noView, "Find", "ActorEnquiry"),
|
||||
false,
|
||||
);
|
||||
check(
|
||||
"view=N 时 Insert 仍为 true",
|
||||
canOperate(noView, "Insert", "ActorEnquiry"),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
console.warn(
|
||||
failed === 0
|
||||
? `permission-kernel: ${total} assertions passed`
|
||||
: `permission-kernel: ${failed}/${total} assertions FAILED`,
|
||||
);
|
||||
if (failed > 0) {
|
||||
// 让非零退出可被脚本捕获,避免"跑了但没人看输出"
|
||||
throw new Error("permission-kernel assertions failed");
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* 权限判定内核:纯函数 + 预建索引,不碰 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;
|
||||
}
|
||||
Reference in new issue
Block a user