feat: add organization policy center

This commit is contained in:
xz
2026-09-01 13:22:04 +08:00
parent a9c8eea271
commit 1d4154dd20
12 changed files with 1565 additions and 12 deletions
+37 -6
View File
@@ -140,6 +140,8 @@ db.exec("CREATE INDEX IF NOT EXISTS idx_generation_jobs_model_approval ON genera
db.exec("CREATE INDEX IF NOT EXISTS idx_commercial_approvals_org_status ON commercial_approval_requests(organization_id, status, updated_at DESC)");
db.exec("CREATE INDEX IF NOT EXISTS idx_commercial_approvals_requester ON commercial_approval_requests(requester_user_id, status, created_at DESC)");
db.exec("CREATE INDEX IF NOT EXISTS idx_commercial_approvals_target ON commercial_approval_requests(organization_id, request_type, target_key, status)");
db.exec("CREATE INDEX IF NOT EXISTS idx_organization_policies_org_category ON organization_policies(organization_id, category, policy_key)");
db.exec("CREATE INDEX IF NOT EXISTS idx_organization_policy_evaluations_scope ON organization_policy_evaluations(organization_id, workspace_id, project_id, result, created_at DESC)");
db.exec("CREATE INDEX IF NOT EXISTS idx_asset_versions_governance ON asset_versions(asset_id, rights_status, expires_at)");
db.exec("CREATE INDEX IF NOT EXISTS idx_asset_governance_reviews_asset ON asset_governance_reviews(asset_id, created_at DESC)");
db.exec("CREATE INDEX IF NOT EXISTS idx_asset_governance_reviews_scope ON asset_governance_reviews(organization_id, workspace_id, project_id, risk_status, created_at DESC)");
@@ -237,6 +239,8 @@ function seedRoles() {
["organization:manage", "修改组织设置"],
["organization:members:invite", "邀请组织成员"],
["organization:roles:manage", "管理组织角色权限策略"],
["policy:read", "查看组织生产、安全和合规策略"],
["policy:manage", "管理组织生产、安全和合规策略"],
["workspace:create", "创建工作区"],
["workspace:manage", "管理工作区设置"],
["workspace:members:manage", "管理工作区成员"],
@@ -285,14 +289,14 @@ function seedRoles() {
org_admin: [
"organization:manage", "organization:members:invite", "workspace:create", "workspace:manage",
"workspace:members:manage", "project:create", "project:manage", "project:members:manage",
"workflow:manage", "task:view", "task:manage", "task:complete", "style:read", "style:manage", "model:manage", "model:approve", "usage:view", "billing:manage", "quota:manage", "qa:review", "delivery:approve", "delivery:view", "compliance:manage", "audit:view", "queue:manage", "voice:approve",
"workflow:manage", "task:view", "task:manage", "task:complete", "policy:read", "policy:manage", "style:read", "style:manage", "model:manage", "model:approve", "usage:view", "billing:manage", "quota:manage", "qa:review", "delivery:approve", "delivery:view", "compliance:manage", "audit:view", "queue:manage", "voice:approve",
"system:settings:view", "service:health:view", "organization:roles:manage"
],
producer: ["project:create", "project:manage", "project:members:manage", "workflow:manage", "task:view", "task:manage", "task:complete", "style:read", "style:manage", "script:read", "job:create", "job:prioritize", "usage:view", "delivery:approve", "delivery:view", "voice:approve"],
writer: ["script:read", "script:edit", "task:view", "task:complete", "style:read", "job:create"],
art_director: ["asset:edit", "prompt:edit", "task:view", "task:complete", "style:read", "style:manage", "job:create"],
voice_editor: ["voice:edit", "voice:approve", "task:view", "task:complete", "style:read", "job:create"],
reviewer: ["script:read", "task:view", "task:complete", "style:read", "qa:review", "voice:approve", "delivery:view"],
producer: ["project:create", "project:manage", "project:members:manage", "workflow:manage", "task:view", "task:manage", "task:complete", "policy:read", "style:read", "style:manage", "script:read", "job:create", "job:prioritize", "usage:view", "delivery:approve", "delivery:view", "voice:approve"],
writer: ["script:read", "script:edit", "task:view", "task:complete", "policy:read", "style:read", "job:create"],
art_director: ["asset:edit", "prompt:edit", "task:view", "task:complete", "policy:read", "style:read", "style:manage", "job:create"],
voice_editor: ["voice:edit", "voice:approve", "task:view", "task:complete", "policy:read", "style:read", "job:create"],
reviewer: ["script:read", "task:view", "task:complete", "policy:read", "style:read", "qa:review", "voice:approve", "delivery:view"],
project_guest: ["script:read", "task:view", "style:read", "delivery:view"],
project_editor: ["script:read", "script:edit", "asset:edit", "prompt:edit", "voice:edit", "task:view", "task:manage", "task:complete", "style:read", "job:create", "delivery:view"],
project_viewer: ["script:read", "task:view", "style:read", "delivery:view"]
@@ -355,6 +359,32 @@ function seedOrganization({ id, name, slug, ownerUserId, description, workspaceI
insertIgnore("INSERT OR IGNORE INTO quota_allocations(id, organization_id, workspace_id, metric, limit_value, used_value, unit, period_start, period_end, created_at, updated_at) VALUES (?, ?, ?, 'storage', 1024, 128, 'GB', ?, ?, ?, ?)", [`quota-${workspaceId}-storage`, id, workspaceId, monthStart(), monthEnd(), timestamp, timestamp]);
}
function rollQuotaPeriods() {
const start = monthStart();
const end = monthEnd();
const timestamp = now();
const rows = dbAll("SELECT * FROM quota_allocations");
for (const row of rows) {
const current = dbGet(
"SELECT julianday(?) <= julianday('now') AND julianday(?) >= julianday('now') AS active",
[row.period_start, row.period_end]
);
if (current?.active) continue;
const usedValue = row.metric === "clip"
? Number(dbGet(
`SELECT COALESCE(SUM(units), 0) AS units
FROM usage_events
WHERE organization_id = ?
AND (workspace_id = ? OR (? IS NULL AND workspace_id IS NULL))
AND unit_name IN ('job', 'clip', 'clips')
AND created_at >= datetime('now', 'start of month')`,
[row.organization_id, row.workspace_id, row.workspace_id]
)?.units || 0)
: Number(row.used_value || 0);
dbRun("UPDATE quota_allocations SET used_value = ?, period_start = ?, period_end = ?, updated_at = ? WHERE id = ?", [usedValue, start, end, timestamp, row.id]);
}
}
function commercialEntitlementDefaults(billing = {}) {
const clipLimit = Number(billing.monthly_clip_quota || 2400);
const storageGb = Number(billing.storage_gb || 1024);
@@ -1239,6 +1269,7 @@ withTransaction(() => {
insertIgnore("INSERT OR IGNORE INTO workspaces(id, organization_id, name, slug, description, status, created_at, updated_at) VALUES ('ws-pilot', 'org-studio-lab', '素材实验室', 'asset-lab', '角色、场景和模型试验空间', 'active', ?, ?)", [now(), now()]);
seedCommercialPlans();
seedOrganizationEntitlements();
rollQuotaPeriods();
seedCommercialApprovals();
seedMembersAndProjects();
seedModels();
+41 -5
View File
@@ -18,6 +18,7 @@ import { dispatchNotificationEvent } from "./notifications.mjs";
import { registerJobArtifacts } from "./media-artifacts.mjs";
import { resolveKnowledgeContextForJob } from "./knowledge.mjs";
import { effectiveStyleKitForProject, styleKitForbiddenTerms, styleKitNegativeTerms } from "./style-kits.mjs";
import { evaluateGenerationPolicy, recordPolicyEvaluation } from "./organization-policies.mjs";
const projectRoot = resolve(import.meta.dirname, "..");
const jobStorageRoot = resolve(projectRoot, "storage", "jobs");
@@ -375,21 +376,38 @@ function buildContract(context, body, adapter, routing = null, preflight = {}) {
const kind = String(body.kind || "自定义生成任务").trim();
const knowledge = resolveKnowledgeContextForJob(context, body);
const styleKit = context.project ? effectiveStyleKitForProject(context, context.project.id).styleKit : null;
const organizationPolicyEvaluation = preflight.organizationPolicy || evaluateGenerationPolicy(context, { body, adapter, routing, shot });
const organizationPolicy = organizationPolicyEvaluation?.effective || null;
const inputs = { ...(body.inputs || {}) };
if (knowledge) {
inputs.knowledgeContext = knowledge.promptContext;
inputs.knowledgeCitations = knowledge.citations;
inputs.knowledgeChunkIds = knowledge.chunkIds;
}
const blockedTerms = styleKitForbiddenTerms(styleKit);
const negativeTerms = styleKitNegativeTerms(styleKit);
const blockedTerms = [...new Set([...styleKitForbiddenTerms(styleKit), ...(organizationPolicy?.generation?.forbiddenTerms || [])])];
const negativeTerms = [...new Set([...styleKitNegativeTerms(styleKit), ...(organizationPolicy?.generation?.forbiddenTerms || [])])];
// Negative prompts intentionally name the forbidden layouts; only inspect positive generation text here.
const promptText = [shot?.prompt, shot?.videoPrompt, shot?.action, shot?.camera, body.prompt].filter(Boolean).join(" ").toLowerCase();
const blocked = blockedTerms.filter((term) => promptText.includes(term.toLowerCase()));
if (blocked.length) throw httpError(422, "single_frame_contract_violation", "请求包含禁止的一图多画面表达", { blocked });
if (organizationPolicyEvaluation?.blockers?.length) {
throw httpError(422, "organization_policy_blocked", "生成请求未通过组织策略", { blockers: organizationPolicyEvaluation.blockers });
}
if (shot && kind.includes("视频") && shot.transitionFromPrevious !== "episode-start" && (!shot.firstFrame || /pending|auto_previous/i.test(shot.firstFrame))) {
throw httpError(422, "actual_last_frame_required", "连续视频镜头必须先登记上一段实际末帧作为首帧输入", { shotId: shot.id });
}
const policySnapshot = organizationPolicy ? {
organizationId: organizationPolicy.organizationId,
generatedAt: organizationPolicy.generatedAt,
model: organizationPolicy.model,
generation: organizationPolicy.generation,
voice: organizationPolicy.voice,
delivery: organizationPolicy.delivery,
compliance: organizationPolicy.compliance,
security: organizationPolicy.security,
retention: organizationPolicy.retention
} : null;
const currentCostMode = routing?.connectorCostMode || adapter.costMode || "local";
return {
schema: "ai-drama.job.v1",
createdAt: now(),
@@ -412,6 +430,14 @@ function buildContract(context, body, adapter, routing = null, preflight = {}) {
preflight,
knowledge,
styleKit,
organizationPolicy: policySnapshot,
policyEvaluation: organizationPolicyEvaluation ? {
result: organizationPolicyEvaluation.result,
blockers: organizationPolicyEvaluation.blockers,
approvals: organizationPolicyEvaluation.approvals,
warnings: organizationPolicyEvaluation.warnings,
checks: organizationPolicyEvaluation.checks
} : null,
shot,
inputs,
constraints: {
@@ -419,15 +445,15 @@ function buildContract(context, body, adapter, routing = null, preflight = {}) {
imageOutputCount: 1,
batchSize: 1,
requireActualLastFrame: true,
localOnly: routing ? routing.policyMode === "local-only" || routing.connectorCostMode === "local" : adapter.costMode === "local",
approvalRequired: Boolean(routing?.requiresApproval || adapter.approvalRequired),
localOnly: currentCostMode === "local" && Boolean(organizationPolicy?.model?.defaultLocalOnly || (routing ? routing.policyMode === "local-only" : adapter.costMode === "local")),
approvalRequired: Boolean(routing?.requiresApproval || adapter.approvalRequired || organizationPolicyEvaluation?.approvals?.length),
blockedTerms,
negativePromptTerms: negativeTerms,
styleEnforcement: styleKit?.source || "default-contract",
aspectRatio: styleKit?.deliverySpec?.aspectRatio || "9:16",
resolution: styleKit?.deliverySpec?.resolution || "1080x1920",
fps: styleKit?.deliverySpec?.fps || 24,
voicePolicy: styleKit?.voicePolicy || null,
voicePolicy: styleKit?.voicePolicy || organizationPolicy?.voice || null,
subtitlePreset: styleKit?.subtitlePreset || null
}
};
@@ -1045,6 +1071,16 @@ export async function createGenerationJob(context, body) {
}
attachModelRouteApprovalToJob(context, approvalGrant, jobId);
});
if (contract.policyEvaluation) {
recordPolicyEvaluation(context, {
...contract.policyEvaluation,
organizationId: context.organization.id,
workspaceId: context.workspace.id,
projectId: context.project.id,
subjectType: "generation_job",
subjectId: jobId
});
}
addUsage({ context, kind: body.kind || "generation", units: 1, unitName: "job", estimatedCost: 0, metadata: { jobId, adapter: adapter.id, modelEntryId: routing?.modelEntryId || null, routeId: routing?.routeId || null, approvalRequestId: approvalGrant?.id || null, status: initialStatus, knowledgePackId: contract.knowledge?.id || null, knowledgeCitations: contract.knowledge?.citations?.length || 0 } });
addAudit({ context, action: "generation_job.created", targetType: "generation_job", targetId: jobId, result: initialStatus === "queued" ? "ok" : "blocked", metadata: { kind: body.kind, shotId: body.shotId || null, adapter: adapter.id, modelEntryId: routing?.modelEntryId || null, routeId: routing?.routeId || null, approvalRequestId: approvalGrant?.id || null, status: initialStatus, knowledgePackId: contract.knowledge?.id || null } });
return { job: jobPayload(dbGet("SELECT * FROM generation_jobs WHERE id = ?", [jobId])), jobs: listGenerationJobs(context) };
+25
View File
@@ -159,6 +159,7 @@ import { reclaimStorage, requireStorageQuota, storageCleanupPreview, storageSumm
import { backupSummary, createDatabaseBackup } from "./backup.mjs";
import { systemReadiness } from "./readiness.mjs";
import { bindProjectStyleKit, createStyleKit, getProjectStyleKit, getStyleKit, listStyleKits, updateStyleKit } from "./style-kits.mjs";
import { evaluateAndRecordOrganizationPolicies, listOrganizationPolicies, updateOrganizationPolicy } from "./organization-policies.mjs";
import { dispatchNotificationEvent, listUserNotificationPreferences, listUserNotifications, markAllUserNotificationsRead, markUserNotificationRead, notificationDeliveries, updateUserNotificationPreference } from "./notifications.mjs";
import { composeProject, listCompositions } from "./composition.mjs";
import { listProjectArtifacts, readArtifactContent } from "./media-artifacts.mjs";
@@ -1852,6 +1853,30 @@ createServer(async (req, res) => {
return send(res, 200, updateCostCenter(context, organizationId, decodeURIComponent(organizationCostCenterMatch[2]), await readBody(req)));
}
const organizationPoliciesEvaluateMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/policies\/evaluate$/);
if (req.method === "POST" && organizationPoliciesEvaluateMatch) {
const organizationId = decodeURIComponent(organizationPoliciesEvaluateMatch[1]);
const context = contextWith({
organizationId,
workspaceId: req.headers["x-workspace-id"] || url.searchParams.get("workspaceId") || "",
projectId: req.headers["x-project-id"] || url.searchParams.get("projectId") || ""
}, req);
return send(res, 200, evaluateAndRecordOrganizationPolicies(context, organizationId, await readBody(req)));
}
const organizationPoliciesMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/policies(?:\/([^/]+))?$/);
if (req.method === "GET" && organizationPoliciesMatch && !organizationPoliciesMatch[2]) {
const organizationId = decodeURIComponent(organizationPoliciesMatch[1]);
const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req);
return send(res, 200, listOrganizationPolicies(context, organizationId, { limit: url.searchParams.get("limit") || 40 }));
}
if (req.method === "PATCH" && organizationPoliciesMatch?.[2]) {
const organizationId = decodeURIComponent(organizationPoliciesMatch[1]);
const policyKey = decodeURIComponent(organizationPoliciesMatch[2]);
const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req);
return send(res, 200, updateOrganizationPolicy(context, organizationId, policyKey, await readBody(req)));
}
const organizationRolePolicyMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/role-policies(?:\/([^/]+))?$/);
if (req.method === "GET" && organizationRolePolicyMatch) {
const organizationId = decodeURIComponent(organizationRolePolicyMatch[1]);
+735
View File
@@ -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)
};
}
+43
View File
@@ -501,6 +501,40 @@ CREATE TABLE IF NOT EXISTS organization_entitlements (
UNIQUE (organization_id, entitlement_key)
);
CREATE TABLE IF NOT EXISTS organization_policies (
id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
policy_key TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'production',
label TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'enforced' CHECK (status IN ('draft', 'enforced', 'monitor', 'disabled')),
enforcement TEXT NOT NULL DEFAULT 'block' CHECK (enforcement IN ('block', 'approval', 'warn', 'off')),
severity TEXT NOT NULL DEFAULT 'high' CHECK (severity IN ('critical', 'high', 'medium', 'low')),
value_json TEXT NOT NULL DEFAULT '{}',
applies_to_json TEXT NOT NULL DEFAULT '[]',
approval_required INTEGER NOT NULL DEFAULT 0,
updated_by TEXT REFERENCES users(id),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (organization_id, policy_key)
);
CREATE TABLE IF NOT EXISTS organization_policy_evaluations (
id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
workspace_id TEXT REFERENCES workspaces(id) ON DELETE SET NULL,
project_id TEXT REFERENCES projects(id) ON DELETE SET NULL,
policy_key TEXT NOT NULL,
subject_type TEXT NOT NULL DEFAULT 'generation_request',
subject_id TEXT NOT NULL DEFAULT '',
result TEXT NOT NULL DEFAULT 'pass' CHECK (result IN ('pass', 'warn', 'approval_required', 'block')),
reason TEXT NOT NULL DEFAULT '',
evidence_json TEXT NOT NULL DEFAULT '{}',
created_by TEXT REFERENCES users(id),
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS commercial_approval_requests (
id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
@@ -574,6 +608,8 @@ CREATE INDEX IF NOT EXISTS idx_organization_invoices_org_status ON organization_
CREATE INDEX IF NOT EXISTS idx_invoice_lines_invoice_sort ON invoice_lines(invoice_id, sort_order, created_at);
CREATE INDEX IF NOT EXISTS idx_subscription_plan_templates_status ON subscription_plan_templates(status, tier_key);
CREATE INDEX IF NOT EXISTS idx_organization_entitlements_org_category ON organization_entitlements(organization_id, category, entitlement_key);
CREATE INDEX IF NOT EXISTS idx_organization_policies_org_category ON organization_policies(organization_id, category, policy_key);
CREATE INDEX IF NOT EXISTS idx_organization_policy_evaluations_scope ON organization_policy_evaluations(organization_id, workspace_id, project_id, result, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_commercial_approvals_org_status ON commercial_approval_requests(organization_id, status, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_commercial_approvals_requester ON commercial_approval_requests(requester_user_id, status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_commercial_approvals_target ON commercial_approval_requests(organization_id, request_type, target_key, status);
@@ -1035,6 +1071,13 @@ CREATE TABLE IF NOT EXISTS generation_jobs (
updated_at TEXT NOT NULL
);
CREATE TRIGGER IF NOT EXISTS trg_policy_evaluations_after_job_delete
AFTER DELETE ON generation_jobs
BEGIN
DELETE FROM organization_policy_evaluations
WHERE subject_type = 'generation_job' AND subject_id = OLD.id;
END;
CREATE TABLE IF NOT EXISTS job_attempts (
id TEXT PRIMARY KEY,
job_id TEXT NOT NULL REFERENCES generation_jobs(id) ON DELETE CASCADE,