feat: add organization policy center
This commit is contained in:
@@ -0,0 +1,735 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { dbAll, dbGet, dbRun, withTransaction } from "./db.mjs";
|
||||
import { addAudit, httpError } from "./tenant.mjs";
|
||||
|
||||
const now = () => new Date().toISOString();
|
||||
|
||||
const DEFAULT_FORBIDDEN_LAYOUT_TERMS = [
|
||||
"split-screen",
|
||||
"comic panel",
|
||||
"collage",
|
||||
"contact sheet",
|
||||
"storyboard",
|
||||
"multi panel",
|
||||
"grid layout",
|
||||
"多格",
|
||||
"拼图",
|
||||
"分屏",
|
||||
"故事板",
|
||||
"多时间点"
|
||||
];
|
||||
|
||||
const DEFAULT_MOUTH_FALLBACK_PLAN = ["侧脸", "背影", "低头", "远景", "反应镜头", "环境插入镜头"];
|
||||
|
||||
export const ORGANIZATION_POLICY_DEFINITIONS = [
|
||||
{
|
||||
key: "model.local_runner_default",
|
||||
category: "model",
|
||||
label: "自有/本地模型优先",
|
||||
description: "默认把生成、解析、配音和检索任务路由到本地或自有模型平台。",
|
||||
status: "enforced",
|
||||
enforcement: "block",
|
||||
severity: "critical",
|
||||
value: {
|
||||
defaultLocalOnly: true,
|
||||
allowedCostModes: ["local", "mixed-with-approval"],
|
||||
preferredAdapter: "owned-model-platform",
|
||||
comfyuiAdapter: "optional"
|
||||
},
|
||||
appliesTo: ["generation_jobs", "model_routes", "connectors"]
|
||||
},
|
||||
{
|
||||
key: "model.external_connector_approval",
|
||||
category: "model",
|
||||
label: "外部/付费连接器审批",
|
||||
description: "第三方官方 API、公网中转和混合成本连接器必须先经过审批,密钥只能用环境变量引用。",
|
||||
status: "enforced",
|
||||
enforcement: "approval",
|
||||
severity: "critical",
|
||||
approvalRequired: true,
|
||||
value: {
|
||||
requireApprovalForExternal: true,
|
||||
requireSecretRefs: true,
|
||||
allowPublicEndpoints: false,
|
||||
approvedWindowHours: 24
|
||||
},
|
||||
appliesTo: ["model_connectors", "model_route_approvals", "generation_jobs"]
|
||||
},
|
||||
{
|
||||
key: "generation.single_frame_contract",
|
||||
category: "production",
|
||||
label: "一图一完整单画面",
|
||||
description: "每次图片生成只能输出一张完整单画面,禁止分屏、多格、漫画拼图、故事板和 contact sheet。",
|
||||
status: "enforced",
|
||||
enforcement: "block",
|
||||
severity: "critical",
|
||||
value: {
|
||||
enabled: true,
|
||||
imageOutputCount: 1,
|
||||
batchSize: 1,
|
||||
forbiddenTerms: DEFAULT_FORBIDDEN_LAYOUT_TERMS,
|
||||
requiredPhrase: "one single complete frame"
|
||||
},
|
||||
appliesTo: ["image_generation", "image_to_video", "prompt_packs", "qa"]
|
||||
},
|
||||
{
|
||||
key: "voice.fixed_voice_required",
|
||||
category: "voice",
|
||||
label: "固定声线与口型规避",
|
||||
description: "最终角色声音必须来自固定参考声线;口型无法同步时,使用侧脸、背影、低头、远景和反应镜头规避。",
|
||||
status: "enforced",
|
||||
enforcement: "block",
|
||||
severity: "critical",
|
||||
value: {
|
||||
finalVoiceMode: "fixed-reference-required",
|
||||
forbidRandomNativeVoice: true,
|
||||
ttsPreference: "local-or-owned-first",
|
||||
fallbackMouthPlan: DEFAULT_MOUTH_FALLBACK_PLAN
|
||||
},
|
||||
appliesTo: ["voice_lines", "tts_jobs", "generation_contracts"]
|
||||
},
|
||||
{
|
||||
key: "delivery.actual_last_frame_required",
|
||||
category: "delivery",
|
||||
label: "连续镜头实际末帧",
|
||||
description: "非开场视频镜头必须引用上一段真实生成出来的末帧,不允许只用占位帧或评审拼图。",
|
||||
status: "enforced",
|
||||
enforcement: "block",
|
||||
severity: "high",
|
||||
value: {
|
||||
requireActualLastFrameForVideo: true,
|
||||
blockPlaceholderFrames: true,
|
||||
acceptedEvidenceKinds: ["last-frame", "media-qa", "delivery-clearance"]
|
||||
},
|
||||
appliesTo: ["image_to_video", "shot_versions", "delivery_clearance"]
|
||||
},
|
||||
{
|
||||
key: "compliance.rights_evidence_required",
|
||||
category: "compliance",
|
||||
label: "素材权利证据必填",
|
||||
description: "小说、角色图、道具图、声音参考和上传素材必须有原创或授权证据,才能进入商业生成与交付。",
|
||||
status: "enforced",
|
||||
enforcement: "block",
|
||||
severity: "high",
|
||||
value: {
|
||||
requiredForKinds: ["knowledge", "asset", "voice", "delivery"],
|
||||
acceptedStatuses: ["approved", "original"],
|
||||
rejectUnknownSource: true
|
||||
},
|
||||
appliesTo: ["knowledge_documents", "asset_versions", "voice_references", "deliveries"]
|
||||
},
|
||||
{
|
||||
key: "security.admin_mfa_required",
|
||||
category: "security",
|
||||
label: "管理账号 MFA",
|
||||
description: "组织所有者、组织管理员、模型管理员、账单管理员和系统管理员建议或强制启用 MFA。",
|
||||
status: "monitor",
|
||||
enforcement: "warn",
|
||||
severity: "medium",
|
||||
value: {
|
||||
requireForAdmins: true,
|
||||
requireForAll: false,
|
||||
methods: ["totp"]
|
||||
},
|
||||
appliesTo: ["users", "sessions", "admin_actions"]
|
||||
},
|
||||
{
|
||||
key: "retention.production_evidence",
|
||||
category: "storage",
|
||||
label: "生产证据留存",
|
||||
description: "保留生成合同、QA 结果、实际末帧、权利证据和交付清算记录,临时缓存按保留周期治理。",
|
||||
status: "enforced",
|
||||
enforcement: "warn",
|
||||
severity: "medium",
|
||||
value: {
|
||||
generationContractDays: 365,
|
||||
deliveryEvidenceDays: 730,
|
||||
temporaryMediaDays: 30,
|
||||
keepAuditLogs: true
|
||||
},
|
||||
appliesTo: ["generation_jobs", "qa_results", "delivery_releases", "audit_logs"]
|
||||
}
|
||||
];
|
||||
|
||||
function parseJson(value, fallback) {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function privateHost(hostname) {
|
||||
const host = String(hostname || "").toLowerCase();
|
||||
if (["localhost", "127.0.0.1", "::1"].includes(host) || host.endsWith(".local")) return true;
|
||||
const octets = host.split(".").map(Number);
|
||||
if (octets.length !== 4 || octets.some((value) => !Number.isInteger(value) || value < 0 || value > 255)) return false;
|
||||
return octets[0] === 10 || octets[0] === 127 || (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) || (octets[0] === 192 && octets[1] === 168);
|
||||
}
|
||||
|
||||
function hasPolicyPermission(context, permission) {
|
||||
return Boolean(context?.systemAdmin || (context?.permissions || []).includes(permission));
|
||||
}
|
||||
|
||||
function requirePolicyRead(context) {
|
||||
if (hasPolicyPermission(context, "policy:read") || hasPolicyPermission(context, "policy:manage") || hasPolicyPermission(context, "organization:manage") || hasPolicyPermission(context, "compliance:manage")) return;
|
||||
throw httpError(403, "permission_denied", "当前角色没有查看组织策略的权限");
|
||||
}
|
||||
|
||||
function requirePolicyManage(context) {
|
||||
if (hasPolicyPermission(context, "policy:manage") || hasPolicyPermission(context, "organization:manage")) return;
|
||||
throw httpError(403, "permission_denied", "当前角色没有管理组织策略的权限");
|
||||
}
|
||||
|
||||
function assertOrganizationScope(context, organizationId) {
|
||||
if (context?.organization?.id === organizationId) return;
|
||||
throw httpError(403, "organization_policy_scope_denied", "不能访问其他组织的策略", { organizationId });
|
||||
}
|
||||
|
||||
function policyId(organizationId, policyKey) {
|
||||
return `policy-${organizationId}-${policyKey.replace(/[^a-z0-9]+/gi, "-")}`;
|
||||
}
|
||||
|
||||
function policyPayload(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
organizationId: row.organization_id,
|
||||
policyKey: row.policy_key,
|
||||
category: row.category,
|
||||
label: row.label,
|
||||
description: row.description || "",
|
||||
status: row.status,
|
||||
enforcement: row.enforcement,
|
||||
severity: row.severity,
|
||||
value: parseJson(row.value_json, {}),
|
||||
appliesTo: parseJson(row.applies_to_json, []),
|
||||
approvalRequired: Boolean(row.approval_required),
|
||||
updatedBy: row.updated_by || "",
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
function evaluationPayload(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
organizationId: row.organization_id,
|
||||
workspaceId: row.workspace_id || "",
|
||||
projectId: row.project_id || "",
|
||||
policyKey: row.policy_key,
|
||||
subjectType: row.subject_type,
|
||||
subjectId: row.subject_id || "",
|
||||
result: row.result,
|
||||
reason: row.reason || "",
|
||||
evidence: parseJson(row.evidence_json, {}),
|
||||
createdBy: row.created_by || "",
|
||||
createdAt: row.created_at
|
||||
};
|
||||
}
|
||||
|
||||
export function ensureOrganizationPolicies(organizationId) {
|
||||
const organization = dbGet("SELECT id, owner_user_id FROM organizations WHERE id = ?", [organizationId]);
|
||||
if (!organization) throw httpError(404, "organization_not_found", "组织不存在", { organizationId });
|
||||
const timestamp = now();
|
||||
for (const definition of ORGANIZATION_POLICY_DEFINITIONS) {
|
||||
dbRun(
|
||||
`INSERT OR IGNORE INTO organization_policies(
|
||||
id, organization_id, policy_key, category, label, description, status,
|
||||
enforcement, severity, value_json, applies_to_json, approval_required,
|
||||
updated_by, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
policyId(organizationId, definition.key),
|
||||
organizationId,
|
||||
definition.key,
|
||||
definition.category,
|
||||
definition.label,
|
||||
definition.description,
|
||||
definition.status,
|
||||
definition.enforcement,
|
||||
definition.severity,
|
||||
JSON.stringify(definition.value || {}),
|
||||
JSON.stringify(definition.appliesTo || []),
|
||||
definition.approvalRequired ? 1 : 0,
|
||||
organization.owner_user_id || null,
|
||||
timestamp,
|
||||
timestamp
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function allPolicyRows(organizationId) {
|
||||
ensureOrganizationPolicies(organizationId);
|
||||
return dbAll("SELECT * FROM organization_policies WHERE organization_id = ? ORDER BY category, policy_key", [organizationId]);
|
||||
}
|
||||
|
||||
function policyRow(organizationId, policyKey) {
|
||||
ensureOrganizationPolicies(organizationId);
|
||||
return dbGet("SELECT * FROM organization_policies WHERE organization_id = ? AND policy_key = ?", [organizationId, policyKey]);
|
||||
}
|
||||
|
||||
function recentEvaluations(organizationId, limit = 40) {
|
||||
return dbAll(
|
||||
`SELECT *
|
||||
FROM organization_policy_evaluations
|
||||
WHERE organization_id = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?`,
|
||||
[organizationId, Math.max(1, Math.min(120, Number(limit || 40)))]
|
||||
).map(evaluationPayload);
|
||||
}
|
||||
|
||||
function categorySummary(policies) {
|
||||
const summary = {
|
||||
total: policies.length,
|
||||
enforced: policies.filter((policy) => policy.status === "enforced").length,
|
||||
monitor: policies.filter((policy) => policy.status === "monitor").length,
|
||||
approval: policies.filter((policy) => policy.enforcement === "approval").length,
|
||||
disabled: policies.filter((policy) => policy.status === "disabled").length,
|
||||
critical: policies.filter((policy) => policy.severity === "critical").length,
|
||||
byCategory: []
|
||||
};
|
||||
const categories = [...new Set(policies.map((policy) => policy.category))];
|
||||
summary.byCategory = categories.map((category) => ({
|
||||
category,
|
||||
total: policies.filter((policy) => policy.category === category).length,
|
||||
enforced: policies.filter((policy) => policy.category === category && policy.status === "enforced").length
|
||||
}));
|
||||
return summary;
|
||||
}
|
||||
|
||||
function findEffectivePolicy(policies, key) {
|
||||
const row = policies.find((policy) => policy.policyKey === key);
|
||||
if (row && row.status !== "disabled") return row;
|
||||
const fallback = ORGANIZATION_POLICY_DEFINITIONS.find((definition) => definition.key === key);
|
||||
return fallback ? {
|
||||
policyKey: fallback.key,
|
||||
category: fallback.category,
|
||||
label: fallback.label,
|
||||
status: fallback.status,
|
||||
enforcement: fallback.enforcement,
|
||||
severity: fallback.severity,
|
||||
value: fallback.value || {},
|
||||
appliesTo: fallback.appliesTo || [],
|
||||
approvalRequired: Boolean(fallback.approvalRequired)
|
||||
} : null;
|
||||
}
|
||||
|
||||
export function organizationPolicySnapshot(contextOrOrganizationId) {
|
||||
const organizationId = typeof contextOrOrganizationId === "string" ? contextOrOrganizationId : contextOrOrganizationId?.organization?.id;
|
||||
if (!organizationId) throw httpError(400, "organization_required", "组织策略需要组织上下文");
|
||||
const policies = allPolicyRows(organizationId).map(policyPayload);
|
||||
const localRunner = findEffectivePolicy(policies, "model.local_runner_default");
|
||||
const externalApproval = findEffectivePolicy(policies, "model.external_connector_approval");
|
||||
const singleFrame = findEffectivePolicy(policies, "generation.single_frame_contract");
|
||||
const voice = findEffectivePolicy(policies, "voice.fixed_voice_required");
|
||||
const lastFrame = findEffectivePolicy(policies, "delivery.actual_last_frame_required");
|
||||
const rights = findEffectivePolicy(policies, "compliance.rights_evidence_required");
|
||||
const security = findEffectivePolicy(policies, "security.admin_mfa_required");
|
||||
const retention = findEffectivePolicy(policies, "retention.production_evidence");
|
||||
const singleFrameValue = singleFrame?.value || {};
|
||||
const voiceValue = voice?.value || {};
|
||||
return {
|
||||
organizationId,
|
||||
generatedAt: now(),
|
||||
policies,
|
||||
model: {
|
||||
defaultLocalOnly: Boolean(localRunner?.value?.defaultLocalOnly ?? true),
|
||||
allowedCostModes: Array.isArray(localRunner?.value?.allowedCostModes) && localRunner.value.allowedCostModes.length ? localRunner.value.allowedCostModes : ["local", "mixed-with-approval"],
|
||||
preferredAdapter: localRunner?.value?.preferredAdapter || "owned-model-platform",
|
||||
comfyuiAdapter: localRunner?.value?.comfyuiAdapter || "optional",
|
||||
externalApprovalRequired: Boolean(externalApproval?.value?.requireApprovalForExternal ?? true),
|
||||
requireSecretRefs: Boolean(externalApproval?.value?.requireSecretRefs ?? true),
|
||||
allowPublicEndpoints: Boolean(externalApproval?.value?.allowPublicEndpoints ?? false),
|
||||
approvedWindowHours: Number(externalApproval?.value?.approvedWindowHours || 24)
|
||||
},
|
||||
generation: {
|
||||
singleFrameOnly: true,
|
||||
imageOutputCount: 1,
|
||||
batchSize: 1,
|
||||
forbiddenTerms: [...new Set([...(Array.isArray(singleFrameValue.forbiddenTerms) ? singleFrameValue.forbiddenTerms : []), ...DEFAULT_FORBIDDEN_LAYOUT_TERMS])],
|
||||
requiredPhrase: singleFrameValue.requiredPhrase || "one single complete frame"
|
||||
},
|
||||
voice: {
|
||||
finalVoiceMode: voiceValue.finalVoiceMode || "fixed-reference-required",
|
||||
forbidRandomNativeVoice: true,
|
||||
ttsPreference: voiceValue.ttsPreference || "local-or-owned-first",
|
||||
fallbackMouthPlan: Array.isArray(voiceValue.fallbackMouthPlan) && voiceValue.fallbackMouthPlan.length ? voiceValue.fallbackMouthPlan : DEFAULT_MOUTH_FALLBACK_PLAN
|
||||
},
|
||||
delivery: {
|
||||
requireActualLastFrameForVideo: Boolean(lastFrame?.value?.requireActualLastFrameForVideo ?? true),
|
||||
blockPlaceholderFrames: Boolean(lastFrame?.value?.blockPlaceholderFrames ?? true),
|
||||
acceptedEvidenceKinds: Array.isArray(lastFrame?.value?.acceptedEvidenceKinds) ? lastFrame.value.acceptedEvidenceKinds : ["last-frame", "media-qa", "delivery-clearance"]
|
||||
},
|
||||
compliance: {
|
||||
rightsEvidenceRequired: Boolean(rights?.value?.rejectUnknownSource ?? true),
|
||||
requiredForKinds: Array.isArray(rights?.value?.requiredForKinds) ? rights.value.requiredForKinds : ["knowledge", "asset", "voice", "delivery"],
|
||||
acceptedStatuses: Array.isArray(rights?.value?.acceptedStatuses) ? rights.value.acceptedStatuses : ["approved", "original"]
|
||||
},
|
||||
security: {
|
||||
adminMfaRequired: Boolean(security?.value?.requireForAdmins ?? true),
|
||||
allUsersMfaRequired: Boolean(security?.value?.requireForAll ?? false),
|
||||
methods: Array.isArray(security?.value?.methods) ? security.value.methods : ["totp"]
|
||||
},
|
||||
retention: {
|
||||
generationContractDays: Number(retention?.value?.generationContractDays || 365),
|
||||
deliveryEvidenceDays: Number(retention?.value?.deliveryEvidenceDays || 730),
|
||||
temporaryMediaDays: Number(retention?.value?.temporaryMediaDays || 30),
|
||||
keepAuditLogs: Boolean(retention?.value?.keepAuditLogs ?? true)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeObject(value, fallback = {}) {
|
||||
if (value === undefined) return fallback;
|
||||
if (typeof value === "string") {
|
||||
const parsed = parseJson(value, null);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
||||
throw httpError(400, "policy_json_invalid", "策略 value 必须是合法 JSON 对象");
|
||||
}
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw httpError(400, "policy_value_invalid", "策略 value 必须是对象");
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeArray(value, fallback = []) {
|
||||
if (value === undefined) return fallback;
|
||||
if (typeof value === "string") {
|
||||
const parsed = parseJson(value, null);
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
throw httpError(400, "policy_json_invalid", "策略 appliesTo 必须是合法 JSON 数组");
|
||||
}
|
||||
if (!Array.isArray(value)) throw httpError(400, "policy_applies_to_invalid", "策略 appliesTo 必须是数组");
|
||||
return value.map((item) => String(item || "").trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function validateHardGate(policyKey, next) {
|
||||
if (policyKey === "generation.single_frame_contract") {
|
||||
if (next.status !== "enforced" || next.enforcement !== "block") {
|
||||
throw httpError(409, "policy_hard_gate_locked", "一图一完整单画面是生产硬门,不能关闭或降级");
|
||||
}
|
||||
if (next.value.enabled === false || Number(next.value.imageOutputCount) !== 1 || Number(next.value.batchSize) !== 1) {
|
||||
throw httpError(409, "policy_hard_gate_locked", "一图一完整单画面必须保持 imageOutputCount=1 且 batchSize=1");
|
||||
}
|
||||
}
|
||||
if (policyKey === "voice.fixed_voice_required") {
|
||||
if (next.status === "disabled" || next.enforcement === "off" || next.value.forbidRandomNativeVoice === false) {
|
||||
throw httpError(409, "policy_hard_gate_locked", "固定声线策略不能允许随机原生声音作为最终方案");
|
||||
}
|
||||
}
|
||||
if (policyKey === "model.external_connector_approval") {
|
||||
if (next.status === "disabled" || next.enforcement === "off" || next.value.requireApprovalForExternal === false) {
|
||||
throw httpError(409, "policy_hard_gate_locked", "外部/付费连接器必须保留显式审批策略");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function listOrganizationPolicies(context, organizationId, options = {}) {
|
||||
assertOrganizationScope(context, organizationId);
|
||||
requirePolicyRead(context);
|
||||
const policies = allPolicyRows(organizationId).map(policyPayload);
|
||||
return {
|
||||
organizationId,
|
||||
summary: categorySummary(policies),
|
||||
policies,
|
||||
effective: organizationPolicySnapshot(organizationId),
|
||||
evaluations: recentEvaluations(organizationId, options.limit || 40)
|
||||
};
|
||||
}
|
||||
|
||||
export function updateOrganizationPolicy(context, organizationId, policyKey, body = {}) {
|
||||
assertOrganizationScope(context, organizationId);
|
||||
requirePolicyManage(context);
|
||||
const current = policyRow(organizationId, policyKey);
|
||||
if (!current) throw httpError(404, "organization_policy_not_found", "组织策略不存在", { policyKey });
|
||||
const currentPayload = policyPayload(current);
|
||||
const status = String(body.status ?? current.status).trim();
|
||||
const enforcement = String(body.enforcement ?? current.enforcement).trim();
|
||||
const severity = String(body.severity ?? current.severity).trim();
|
||||
if (!["draft", "enforced", "monitor", "disabled"].includes(status)) throw httpError(400, "policy_status_invalid", "策略状态无效");
|
||||
if (!["block", "approval", "warn", "off"].includes(enforcement)) throw httpError(400, "policy_enforcement_invalid", "策略执行方式无效");
|
||||
if (!["critical", "high", "medium", "low"].includes(severity)) throw httpError(400, "policy_severity_invalid", "策略级别无效");
|
||||
const next = {
|
||||
label: String(body.label ?? current.label).trim(),
|
||||
description: String(body.description ?? current.description ?? "").trim(),
|
||||
status,
|
||||
enforcement,
|
||||
severity,
|
||||
value: normalizeObject(body.value ?? body.valueJson, currentPayload.value),
|
||||
appliesTo: normalizeArray(body.appliesTo ?? body.applies_to, currentPayload.appliesTo),
|
||||
approvalRequired: body.approvalRequired === undefined ? Boolean(current.approval_required) : Boolean(body.approvalRequired)
|
||||
};
|
||||
if (!next.label) throw httpError(400, "policy_label_required", "策略名称不能为空");
|
||||
validateHardGate(policyKey, next);
|
||||
const timestamp = now();
|
||||
dbRun(
|
||||
`UPDATE organization_policies
|
||||
SET label = ?, description = ?, status = ?, enforcement = ?, severity = ?,
|
||||
value_json = ?, applies_to_json = ?, approval_required = ?, updated_by = ?, updated_at = ?
|
||||
WHERE organization_id = ? AND policy_key = ?`,
|
||||
[
|
||||
next.label,
|
||||
next.description,
|
||||
next.status,
|
||||
next.enforcement,
|
||||
next.severity,
|
||||
JSON.stringify(next.value),
|
||||
JSON.stringify(next.appliesTo),
|
||||
next.approvalRequired ? 1 : 0,
|
||||
context.user.id,
|
||||
timestamp,
|
||||
organizationId,
|
||||
policyKey
|
||||
]
|
||||
);
|
||||
addAudit({
|
||||
context,
|
||||
action: "organization.policy.updated",
|
||||
targetType: "organization_policy",
|
||||
targetId: `${organizationId}:${policyKey}`,
|
||||
metadata: { previous: currentPayload, value: next }
|
||||
});
|
||||
return listOrganizationPolicies(context, organizationId, { limit: 40 });
|
||||
}
|
||||
|
||||
function costModeAllowed(costMode, allowedCostModes) {
|
||||
if (allowedCostModes.includes(costMode)) return true;
|
||||
if (costMode === "mixed" && allowedCostModes.includes("mixed-with-approval")) return true;
|
||||
if (costMode === "cloud" && allowedCostModes.includes("cloud-with-approval")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function endpointIsPublic(endpoint) {
|
||||
if (!endpoint) return false;
|
||||
try {
|
||||
const url = new URL(String(endpoint));
|
||||
return !privateHost(url.hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function checkResult(priority) {
|
||||
if (priority.blockers.length) return "block";
|
||||
if (priority.approvals.length) return "approval_required";
|
||||
if (priority.warnings.length) return "warn";
|
||||
return "pass";
|
||||
}
|
||||
|
||||
function addCheck(target, check) {
|
||||
target.checks.push(check);
|
||||
if (check.result === "block") target.blockers.push(check);
|
||||
else if (check.result === "approval_required") target.approvals.push(check);
|
||||
else if (check.result === "warn") target.warnings.push(check);
|
||||
}
|
||||
|
||||
export function evaluateOrganizationPolicies(context, organizationId, input = {}, options = {}) {
|
||||
assertOrganizationScope(context, organizationId);
|
||||
if (options.requireRead !== false) requirePolicyRead(context);
|
||||
const effective = organizationPolicySnapshot(organizationId);
|
||||
const checks = { checks: [], blockers: [], approvals: [], warnings: [] };
|
||||
const subjectType = String(input.subjectType || "generation_request");
|
||||
const subjectId = String(input.subjectId || input.jobId || "");
|
||||
const promptText = [
|
||||
input.prompt,
|
||||
input.positivePrompt,
|
||||
input.title,
|
||||
input.shot?.prompt,
|
||||
input.shot?.videoPrompt,
|
||||
input.shot?.action,
|
||||
input.shot?.camera
|
||||
].filter(Boolean).join(" ").toLowerCase();
|
||||
const forbiddenHits = effective.generation.forbiddenTerms.filter((term) => promptText.includes(String(term).toLowerCase()));
|
||||
const imageOutputCount = Number(input.imageOutputCount ?? input.image_output_count ?? effective.generation.imageOutputCount);
|
||||
const batchSize = Number(input.batchSize ?? input.batch_size ?? effective.generation.batchSize);
|
||||
if (forbiddenHits.length || imageOutputCount !== 1 || batchSize !== 1) {
|
||||
addCheck(checks, {
|
||||
policyKey: "generation.single_frame_contract",
|
||||
result: "block",
|
||||
severity: "critical",
|
||||
reason: forbiddenHits.length ? `检测到一图多画面风险词:${forbiddenHits.join("、")}` : "图片生成必须保持 imageOutputCount=1 且 batchSize=1",
|
||||
evidence: { forbiddenHits, imageOutputCount, batchSize }
|
||||
});
|
||||
} else {
|
||||
addCheck(checks, {
|
||||
policyKey: "generation.single_frame_contract",
|
||||
result: "pass",
|
||||
severity: "critical",
|
||||
reason: "单画面输出数量与 prompt 风险通过",
|
||||
evidence: { imageOutputCount: 1, batchSize: 1 }
|
||||
});
|
||||
}
|
||||
|
||||
const costMode = String(input.costMode || input.adapter?.costMode || input.adapter?.cost_mode || input.routing?.connectorCostMode || "local").toLowerCase();
|
||||
const endpoint = input.endpoint || input.adapter?.endpoint || input.adapter?.baseUrl || "";
|
||||
const hasApprovalGrant = Boolean(input.approvalGrant || input.approvedExternal || input.modelRouteApproval);
|
||||
if (!costModeAllowed(costMode, effective.model.allowedCostModes)) {
|
||||
addCheck(checks, {
|
||||
policyKey: "model.local_runner_default",
|
||||
result: "block",
|
||||
severity: "critical",
|
||||
reason: `当前成本策略 ${costMode} 不在组织允许范围内`,
|
||||
evidence: { costMode, allowedCostModes: effective.model.allowedCostModes }
|
||||
});
|
||||
} else if (costMode !== "local" && effective.model.externalApprovalRequired && !hasApprovalGrant) {
|
||||
addCheck(checks, {
|
||||
policyKey: "model.external_connector_approval",
|
||||
result: "approval_required",
|
||||
severity: "critical",
|
||||
reason: "外部或混合成本连接器需要显式审批后才能执行",
|
||||
evidence: { costMode, approvedWindowHours: effective.model.approvedWindowHours }
|
||||
});
|
||||
} else {
|
||||
addCheck(checks, {
|
||||
policyKey: costMode === "local" ? "model.local_runner_default" : "model.external_connector_approval",
|
||||
result: "pass",
|
||||
severity: "critical",
|
||||
reason: costMode === "local" ? "命中本地/自有模型策略" : "外部连接器已有审批授权",
|
||||
evidence: { costMode }
|
||||
});
|
||||
}
|
||||
if (endpointIsPublic(endpoint) && !effective.model.allowPublicEndpoints && !hasApprovalGrant) {
|
||||
addCheck(checks, {
|
||||
policyKey: "model.external_connector_approval",
|
||||
result: "approval_required",
|
||||
severity: "critical",
|
||||
reason: "公网模型端点需要审批后才能接入",
|
||||
evidence: { endpointPolicy: "public-network" }
|
||||
});
|
||||
}
|
||||
|
||||
const randomNativeVoice = Boolean(input.randomNativeVoice || input.voice?.randomNativeVoice || String(input.voiceMode || input.voice?.mode || "").toLowerCase() === "random-native");
|
||||
if (randomNativeVoice) {
|
||||
addCheck(checks, {
|
||||
policyKey: "voice.fixed_voice_required",
|
||||
result: "block",
|
||||
severity: "critical",
|
||||
reason: "随机原生声音不能作为最终角色声线",
|
||||
evidence: { finalVoiceMode: effective.voice.finalVoiceMode, fallbackMouthPlan: effective.voice.fallbackMouthPlan }
|
||||
});
|
||||
} else {
|
||||
addCheck(checks, {
|
||||
policyKey: "voice.fixed_voice_required",
|
||||
result: "pass",
|
||||
severity: "critical",
|
||||
reason: "固定声线策略通过",
|
||||
evidence: { finalVoiceMode: effective.voice.finalVoiceMode }
|
||||
});
|
||||
}
|
||||
|
||||
const kind = String(input.kind || input.jobKind || "").toLowerCase();
|
||||
const transitionFromPrevious = input.transitionFromPrevious || input.shot?.transitionFromPrevious;
|
||||
const firstFrame = input.firstFrame || input.shot?.firstFrame || "";
|
||||
if (effective.delivery.requireActualLastFrameForVideo && (kind.includes("视频") || kind.includes("video") || kind.includes("i2v")) && transitionFromPrevious && transitionFromPrevious !== "episode-start" && (!firstFrame || /pending|auto_previous/i.test(firstFrame))) {
|
||||
addCheck(checks, {
|
||||
policyKey: "delivery.actual_last_frame_required",
|
||||
result: "block",
|
||||
severity: "high",
|
||||
reason: "连续视频镜头缺少上一段实际末帧证据",
|
||||
evidence: { transitionFromPrevious, firstFrame: firstFrame || "missing" }
|
||||
});
|
||||
}
|
||||
|
||||
if (input.rightsEvidenceApproved === false && effective.compliance.rightsEvidenceRequired) {
|
||||
addCheck(checks, {
|
||||
policyKey: "compliance.rights_evidence_required",
|
||||
result: "block",
|
||||
severity: "high",
|
||||
reason: "商业生成或交付缺少素材权利证据",
|
||||
evidence: { requiredForKinds: effective.compliance.requiredForKinds }
|
||||
});
|
||||
}
|
||||
|
||||
const result = checkResult(checks);
|
||||
const evaluation = {
|
||||
organizationId,
|
||||
workspaceId: context.workspace?.id || "",
|
||||
projectId: context.project?.id || "",
|
||||
subjectType,
|
||||
subjectId,
|
||||
result,
|
||||
checkedAt: now(),
|
||||
effective,
|
||||
checks: checks.checks,
|
||||
blockers: checks.blockers,
|
||||
approvals: checks.approvals,
|
||||
warnings: checks.warnings
|
||||
};
|
||||
if (options.record) recordPolicyEvaluation(context, evaluation);
|
||||
return evaluation;
|
||||
}
|
||||
|
||||
export function evaluateGenerationPolicy(context, { body = {}, adapter = {}, routing = null, shot = null, approvalGrant = null } = {}) {
|
||||
return evaluateOrganizationPolicies(context, context.organization.id, {
|
||||
subjectType: "generation_request",
|
||||
subjectId: body.jobId || body.shotId || "",
|
||||
kind: body.kind,
|
||||
prompt: body.prompt,
|
||||
imageOutputCount: body.imageOutputCount,
|
||||
batchSize: body.batchSize,
|
||||
adapter,
|
||||
routing,
|
||||
costMode: routing?.connectorCostMode || adapter?.costMode,
|
||||
endpoint: adapter?.endpoint || adapter?.baseUrl,
|
||||
shot,
|
||||
approvalGrant
|
||||
}, { requireRead: false, record: false });
|
||||
}
|
||||
|
||||
export function recordPolicyEvaluation(context, evaluation) {
|
||||
const timestamp = now();
|
||||
const subjectId = evaluation.subjectId || `${evaluation.subjectType}-${Date.now()}`;
|
||||
const checks = evaluation.checks?.length ? evaluation.checks : [{
|
||||
policyKey: "organization.policy.bundle",
|
||||
result: evaluation.result || "pass",
|
||||
reason: "组织策略评估",
|
||||
evidence: {}
|
||||
}];
|
||||
withTransaction(() => {
|
||||
for (const check of checks) {
|
||||
dbRun(
|
||||
`INSERT INTO organization_policy_evaluations(
|
||||
id, organization_id, workspace_id, project_id, policy_key,
|
||||
subject_type, subject_id, result, reason, evidence_json, created_by, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
`pol-eval-${Date.now()}-${randomBytes(4).toString("hex")}`,
|
||||
evaluation.organizationId || context.organization.id,
|
||||
evaluation.workspaceId || context.workspace?.id || null,
|
||||
evaluation.projectId || context.project?.id || null,
|
||||
check.policyKey || "organization.policy.bundle",
|
||||
evaluation.subjectType || "generation_request",
|
||||
subjectId,
|
||||
check.result || evaluation.result || "pass",
|
||||
check.reason || "",
|
||||
JSON.stringify(check.evidence || {}),
|
||||
context.user?.id || null,
|
||||
timestamp
|
||||
]
|
||||
);
|
||||
}
|
||||
});
|
||||
addAudit({
|
||||
context,
|
||||
action: "organization.policy.evaluated",
|
||||
targetType: evaluation.subjectType || "generation_request",
|
||||
targetId: subjectId,
|
||||
result: evaluation.result || "pass",
|
||||
metadata: {
|
||||
result: evaluation.result,
|
||||
blockers: evaluation.blockers?.map((item) => item.policyKey) || [],
|
||||
approvals: evaluation.approvals?.map((item) => item.policyKey) || []
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function evaluateAndRecordOrganizationPolicies(context, organizationId, body = {}) {
|
||||
assertOrganizationScope(context, organizationId);
|
||||
requirePolicyRead(context);
|
||||
const evaluation = evaluateOrganizationPolicies(context, organizationId, body, { requireRead: false, record: true });
|
||||
return {
|
||||
organizationId,
|
||||
evaluation,
|
||||
evaluations: recentEvaluations(organizationId, 40)
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user