74 lines
3.4 KiB
TypeScript
74 lines
3.4 KiB
TypeScript
/**
|
||
* 策略执行(任务 A4.2):在一次调用真正落到 Provider 之前,给出允许 / 拒绝 / 需审批。
|
||
*
|
||
* 三条不变量:
|
||
* 1. 只返回这三种结论,没有第四种「平台替业务系统放行」
|
||
* 2. **平台允许不能覆盖业务系统的拒绝**:本层只做「能不能提交」,业务系统说不行就是不行,
|
||
* 结果由 Provider 的回执决定,Core 不把 forbidden 改写成成功,也不自动重试写操作
|
||
* 3. 未绑定身份默认拒绝——不做「拿共享账号顶替」这种隐式降级(架构设计 4.1)
|
||
*
|
||
* 判定顺序固定(先可见性、再身份、再会话范围、最后动作分级),顺序变了结论就会变。
|
||
* 这一层是可替换的:接 OPA 之类通用策略引擎时实现同一个 PolicyEngine 接口即可。
|
||
*/
|
||
import type { Capability } from '@aps/provider-sdk';
|
||
|
||
import type { AccountBinding } from '../identity/context.js';
|
||
|
||
export type PolicyDecisionKind = 'allow' | 'deny' | 'require-approval';
|
||
|
||
export interface PolicyDecision {
|
||
decision: PolicyDecisionKind;
|
||
/** 机器可读的原因码:界面按它选提示,日志与审计按它聚合 */
|
||
code: string;
|
||
/** 给人看的一句话原因 */
|
||
reason: string;
|
||
}
|
||
|
||
export interface PolicyInput {
|
||
subject: { userId: string; tenantId: string };
|
||
capability: Capability;
|
||
providerId: string;
|
||
/** 平台用户在该系统的账号绑定;未绑定为 undefined */
|
||
binding: AccountBinding | undefined;
|
||
/** 能力可见性结果(A3.5 的白名单与只读模式) */
|
||
visible: boolean;
|
||
/** 该系统是否需要平台用户绑定账号(来自 Provider 声明);不需要时跳过绑定检查 */
|
||
requiresIdentity: boolean;
|
||
/** 会话所属项目;个人会话为 null(ADR-0003:写操作必须在项目会话内) */
|
||
projectId: string | null;
|
||
}
|
||
|
||
export interface PolicyEngine {
|
||
decide(input: PolicyInput): PolicyDecision;
|
||
}
|
||
|
||
const ALLOW = (code: string, reason: string): PolicyDecision => ({ decision: 'allow', code, reason });
|
||
const DENY = (code: string, reason: string): PolicyDecision => ({ decision: 'deny', code, reason });
|
||
|
||
/** 首版规则:可见性 → 身份绑定 → 会话范围 → 动作分级 */
|
||
export function createRulePolicy(): PolicyEngine {
|
||
return {
|
||
decide(input: PolicyInput): PolicyDecision {
|
||
if (!input.visible) {
|
||
return DENY('policy.capability_not_visible', `当前身份看不到能力 ${input.capability.id}`);
|
||
}
|
||
if (input.requiresIdentity && !input.binding) {
|
||
return DENY('policy.identity_unbound', `还没有绑定 ${input.providerId} 的系统账号,先绑定再调用`);
|
||
}
|
||
if (input.binding?.status === 'disabled') {
|
||
return DENY('policy.identity_disabled', `${input.providerId} 的系统账号已停用`);
|
||
}
|
||
if (input.capability.effect === 'write' && input.projectId === null) {
|
||
return DENY('policy.write_requires_project', '写操作必须在项目会话里发起(个人会话只读)');
|
||
}
|
||
if (input.capability.effect === 'write') {
|
||
return {
|
||
decision: 'require-approval',
|
||
code: 'policy.write_requires_confirmation',
|
||
reason: `写操作 ${input.capability.id} 需要用户确认后才执行`,
|
||
};
|
||
}
|
||
return ALLOW(input.capability.effect === 'draft' ? 'policy.draft_allowed' : 'policy.allowed', '平台侧策略允许');
|
||
},
|
||
};
|
||
} |