From 1d4154dd20fc00a2ce9ae968122ff656ead9fb98 Mon Sep 17 00:00:00 2001 From: xz Date: Tue, 1 Sep 2026 13:22:04 +0800 Subject: [PATCH] feat: add organization policy center --- package.json | 1 + scripts/smoke-all.mjs | 1 + scripts/smoke-organization-policies.mjs | 188 ++++++ server/db.mjs | 43 +- server/execution.mjs | 46 +- server/local-api.mjs | 25 + server/organization-policies.mjs | 735 ++++++++++++++++++++++++ server/schema.sql | 43 ++ src/App.jsx | 5 +- src/components/EnterprisePages.jsx | 243 ++++++++ src/lib/api.js | 12 + src/styles.css | 235 ++++++++ 12 files changed, 1565 insertions(+), 12 deletions(-) create mode 100644 scripts/smoke-organization-policies.mjs create mode 100644 server/organization-policies.mjs diff --git a/package.json b/package.json index 9420202..a5ecc80 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "smoke:identity": "node scripts/smoke-identity.mjs", "smoke:system-users": "node scripts/smoke-system-users.mjs", "smoke:commercial-governance": "node scripts/smoke-commercial-governance.mjs", + "smoke:organization-policies": "node scripts/smoke-organization-policies.mjs", "smoke:invitations": "node scripts/smoke-invitations.mjs", "smoke:commercial-ops": "node scripts/smoke-commercial-ops.mjs", "smoke:commercial-approvals": "node scripts/smoke-commercial-approvals.mjs", diff --git a/scripts/smoke-all.mjs b/scripts/smoke-all.mjs index 59f6c74..df88e0b 100644 --- a/scripts/smoke-all.mjs +++ b/scripts/smoke-all.mjs @@ -9,6 +9,7 @@ const scripts = [ "smoke:identity", "smoke:system-users", "smoke:commercial-governance", + "smoke:organization-policies", "smoke:invitations", "smoke:commercial-ops", "smoke:commercial-approvals", diff --git a/scripts/smoke-organization-policies.mjs b/scripts/smoke-organization-policies.mjs new file mode 100644 index 0000000..64ec69b --- /dev/null +++ b/scripts/smoke-organization-policies.mjs @@ -0,0 +1,188 @@ +import assert from "node:assert/strict"; +import { rm } from "node:fs/promises"; +import { resolve } from "node:path"; +import { dbRun, withTransaction } from "../server/db.mjs"; + +const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787"; +const projectRoot = resolve(import.meta.dirname, ".."); +const organizationId = "org-studio-lab"; +const workspaceId = "ws-local-aidrama"; +const projectId = "thunder-mouth"; +const createdJobIds = []; +const evaluationSubjectIds = []; + +async function request(path, options = {}) { + const response = await fetch(`${api}${path}`, { + ...options, + headers: { "content-type": "application/json", ...(options.headers || {}) } + }); + const payload = await response.json().catch(() => ({})); + return { response, payload }; +} + +function expectOk(result, label) { + assert.equal(result.response.ok, true, `${label}: ${result.response.status} ${result.payload.detail || result.payload.error || ""}`); + return result.payload; +} + +async function login(email) { + const result = expectOk(await request("/api/auth/login", { + method: "POST", + body: JSON.stringify({ email, password: "Demo@123456" }) + }), `${email} login`); + return { authorization: `Bearer ${result.session.token}` }; +} + +const ownerAuth = await login("producer@local.test"); +const writerAuth = await login("writer@local.test"); +const headers = { ...ownerAuth, "x-organization-id": organizationId, "x-workspace-id": workspaceId, "x-project-id": projectId }; +const writerHeaders = { ...writerAuth, "x-organization-id": organizationId, "x-workspace-id": workspaceId, "x-project-id": projectId }; + +let originalRetentionPolicy = null; + +try { + const initial = expectOk(await request(`/api/organizations/${organizationId}/policies`, { headers }), "list organization policies"); + assert.ok(initial.policies.length >= 8, "组织策略必须包含默认商业硬门"); + assert.equal(initial.effective.generation.imageOutputCount, 1, "组织策略必须锁定单张图片输出"); + assert.equal(initial.effective.generation.batchSize, 1, "组织策略必须锁定 batchSize=1"); + assert.equal(initial.effective.voice.forbidRandomNativeVoice, true, "组织策略必须禁止随机原生声线作为最终方案"); + assert.equal(initial.effective.model.externalApprovalRequired, true, "组织策略必须要求外部连接器审批"); + + const writerRead = await request(`/api/organizations/${organizationId}/policies`, { headers: writerHeaders }); + assert.equal(writerRead.response.status, 200, "编剧可以读取组织策略用于生产自检"); + + const writerPatch = await request(`/api/organizations/${organizationId}/policies/retention.production_evidence`, { + method: "PATCH", + headers: writerHeaders, + body: JSON.stringify({ value: { temporaryMediaDays: 90 } }) + }); + assert.equal(writerPatch.response.status, 403, "编剧不能修改组织策略"); + + const disableHardGate = await request(`/api/organizations/${organizationId}/policies/generation.single_frame_contract`, { + method: "PATCH", + headers, + body: JSON.stringify({ status: "disabled", enforcement: "off" }) + }); + assert.equal(disableHardGate.response.status, 409, "一图一完整单画面硬门不能被关闭"); + assert.equal(disableHardGate.payload.error, "policy_hard_gate_locked", "硬门降级错误码必须稳定"); + + originalRetentionPolicy = initial.policies.find((policy) => policy.policyKey === "retention.production_evidence"); + const patched = expectOk(await request(`/api/organizations/${organizationId}/policies/retention.production_evidence`, { + method: "PATCH", + headers, + body: JSON.stringify({ + ...originalRetentionPolicy, + value: { ...originalRetentionPolicy.value, temporaryMediaDays: 45 } + }) + }), "update editable retention policy"); + assert.equal(patched.policies.find((policy) => policy.policyKey === "retention.production_evidence").value.temporaryMediaDays, 45, "可编辑策略必须落库"); + + const passSubjectId = `policy-pass-${Date.now()}`; + evaluationSubjectIds.push(passSubjectId); + const passEvaluation = expectOk(await request(`/api/organizations/${organizationId}/policies/evaluate`, { + method: "POST", + headers, + body: JSON.stringify({ + subjectType: "generation_request", + subjectId: passSubjectId, + kind: "单画面关键帧", + prompt: "国漫 2D 动画风格,雨夜地铁口,一个完整单画面。", + costMode: "local", + endpoint: "http://127.0.0.1:8000/v1", + imageOutputCount: 1, + batchSize: 1, + randomNativeVoice: false, + rightsEvidenceApproved: true + }) + }), "evaluate passing request"); + assert.equal(passEvaluation.evaluation.result, "pass", "本地单画面生成请求应通过组织策略"); + + const blockedSubjectId = `policy-block-${Date.now()}`; + evaluationSubjectIds.push(blockedSubjectId); + const blockedEvaluation = expectOk(await request(`/api/organizations/${organizationId}/policies/evaluate`, { + method: "POST", + headers, + body: JSON.stringify({ + subjectType: "generation_request", + subjectId: blockedSubjectId, + kind: "单画面关键帧", + prompt: "请生成 split-screen 对比图和漫画多格拼图。", + costMode: "local", + imageOutputCount: 2, + batchSize: 2, + randomNativeVoice: true, + rightsEvidenceApproved: true + }) + }), "evaluate blocked request"); + assert.equal(blockedEvaluation.evaluation.result, "block", "分屏/多图/随机声线请求必须被组织策略阻断"); + assert.ok(blockedEvaluation.evaluation.blockers.some((item) => item.policyKey === "generation.single_frame_contract"), "阻断结果必须包含单画面硬门"); + assert.ok(blockedEvaluation.evaluation.blockers.some((item) => item.policyKey === "voice.fixed_voice_required"), "阻断结果必须包含固定声线硬门"); + + const cloudSubjectId = `policy-cloud-${Date.now()}`; + evaluationSubjectIds.push(cloudSubjectId); + const cloudEvaluation = expectOk(await request(`/api/organizations/${organizationId}/policies/evaluate`, { + method: "POST", + headers, + body: JSON.stringify({ + subjectType: "connector_request", + subjectId: cloudSubjectId, + kind: "图生视频", + prompt: "原创国漫单画面", + costMode: "cloud", + endpoint: "https://api.example.com/v1", + imageOutputCount: 1, + batchSize: 1, + randomNativeVoice: false, + rightsEvidenceApproved: true + }) + }), "evaluate cloud connector request"); + assert.equal(cloudEvaluation.evaluation.result, "block", "默认商业策略不能直接放行 cloud 成本模式"); + + const preview = expectOk(await request("/api/jobs/preview", { + method: "POST", + headers, + body: JSON.stringify({ kind: "单画面关键帧", shotId: "shot-01", adapter: "owned-model-platform" }) + }), "preview job with organization policy"); + assert.equal(preview.preview.organizationPolicy.generation.batchSize, 1, "生成预览合同必须包含组织策略快照"); + assert.equal(preview.preview.policyEvaluation.result, "pass", "生成预览必须返回策略评估结果"); + assert.ok(preview.preview.constraints.negativePromptTerms.includes("contact sheet"), "生成合同必须注入组织级单画面禁止词"); + + const created = expectOk(await request("/api/jobs", { + method: "POST", + headers, + body: JSON.stringify({ kind: "单画面关键帧", shotId: "shot-01", adapter: "owned-model-platform", output: "qa/policy-contract/frame.json" }) + }), "create job with organization policy"); + createdJobIds.push(created.job.id); + assert.equal(created.job.request.organizationPolicy.generation.imageOutputCount, 1, "创建任务必须持久化组织策略快照"); + assert.equal(created.job.request.policyEvaluation.result, "pass", "创建任务必须持久化组织策略评估"); + + const afterCreate = expectOk(await request(`/api/organizations/${organizationId}/policies`, { headers }), "read policy evaluations after job"); + assert.ok(afterCreate.evaluations.some((item) => item.subjectId === created.job.id), "创建任务后必须写入策略评估 ledger"); + + console.log(`organization policy smoke passed: ${api}`); +} finally { + if (originalRetentionPolicy) { + await request(`/api/organizations/${organizationId}/policies/retention.production_evidence`, { + method: "PATCH", + headers, + body: JSON.stringify(originalRetentionPolicy) + }).catch(() => {}); + } + withTransaction(() => { + for (const jobId of createdJobIds) { + dbRun("DELETE FROM job_dependencies WHERE job_id = ? OR depends_on_job_id = ?", [jobId, jobId]); + dbRun("DELETE FROM job_attempts WHERE job_id = ?", [jobId]); + dbRun("DELETE FROM usage_events WHERE metadata_json LIKE ?", [`%${jobId}%`]); + dbRun("DELETE FROM audit_logs WHERE target_id = ?", [jobId]); + dbRun("DELETE FROM organization_policy_evaluations WHERE subject_id = ?", [jobId]); + dbRun("DELETE FROM generation_jobs WHERE id = ?", [jobId]); + } + for (const subjectId of evaluationSubjectIds) { + dbRun("DELETE FROM organization_policy_evaluations WHERE subject_id = ?", [subjectId]); + dbRun("DELETE FROM audit_logs WHERE target_id = ?", [subjectId]); + } + }); + for (const jobId of createdJobIds) { + await rm(resolve(projectRoot, "storage", "jobs", jobId), { recursive: true, force: true }); + } +} diff --git a/server/db.mjs b/server/db.mjs index b5a830c..6c44018 100644 --- a/server/db.mjs +++ b/server/db.mjs @@ -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(); diff --git a/server/execution.mjs b/server/execution.mjs index df2fcea..17b9b76 100644 --- a/server/execution.mjs +++ b/server/execution.mjs @@ -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) }; diff --git a/server/local-api.mjs b/server/local-api.mjs index 5ce5366..77c10e2 100644 --- a/server/local-api.mjs +++ b/server/local-api.mjs @@ -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]); diff --git a/server/organization-policies.mjs b/server/organization-policies.mjs new file mode 100644 index 0000000..1137ae8 --- /dev/null +++ b/server/organization-policies.mjs @@ -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) + }; +} diff --git a/server/schema.sql b/server/schema.sql index a1db62c..c9b89ed 100644 --- a/server/schema.sql +++ b/server/schema.sql @@ -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, diff --git a/src/App.jsx b/src/App.jsx index 05944f2..c1dd4d5 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -54,6 +54,7 @@ import { AdminAuditPage, AdminModelsPage, AdminOverviewPage, + AdminPolicyCenterPage, AdminStyleKitsPage, AdminQueuePage, AdminUsagePage, @@ -151,6 +152,7 @@ const navigationGroups = [ { id: "admin-overview", label: "管理概览", icon: Gauge, requiredPermission: "usage:view" }, { id: "admin-organizations", label: "组织与工作区", icon: Building2, requiredPermission: "organization:manage" }, { id: "admin-members", label: "用户与权限", icon: Users, requiredPermission: "organization:members:invite" }, + { id: "admin-policies", label: "组织策略", icon: ShieldCheck, requiredAnyPermissions: ["policy:read", "policy:manage", "organization:manage"] }, { id: "admin-style-kits", label: "风格标准", icon: Palette, requiredAnyPermissions: ["style:read", "style:manage"] }, { id: "admin-models", label: "模型与 Runner", icon: Network, requiredAnyPermissions: ["model:manage", "model:approve"] }, { id: "admin-queue", label: "队列与任务", icon: Wand2, requiredPermission: "queue:manage" }, @@ -1713,7 +1715,7 @@ function App() { }; const enterpriseArea = activeTab === "creator-home" || activeTab === "tasks" || activeTab === "assistant" || activeTab === "knowledge" || activeTab === "asset-library" || activeTab === "voice-studio" || activeTab === "batch-production" || activeTab === "account" || activeTab === "factory" || activeTab === "script" || activeTab === "casting" || activeTab === "director" || activeTab === "jobs" || activeTab === "bible" || activeTab === "locks" || activeTab === "shots" || activeTab === "modelops" || activeTab === "qa" || activeTab === "export" || activeTab === "delivery-portal" || activeTab.startsWith("admin-") || activeTab.startsWith("system-"); const effectivePermissions = new Set(platformContext?.context?.permissions || []); - const canViewAdmin = ["workspace:create", "model:manage", "model:approve", "style:manage", "style:read", "usage:view", "compliance:manage", "audit:view", "queue:manage"].some((permission) => effectivePermissions.has(permission)); + const canViewAdmin = ["workspace:create", "model:manage", "model:approve", "policy:manage", "policy:read", "organization:manage", "style:manage", "style:read", "usage:view", "compliance:manage", "audit:view", "queue:manage"].some((permission) => effectivePermissions.has(permission)); const canViewSystem = platformContext?.context?.systemAdmin || effectivePermissions.has("system:settings:view"); const canCreateProject = effectivePermissions.has("project:create"); const canCreateJob = effectivePermissions.has("job:create"); @@ -1891,6 +1893,7 @@ function App() { {activeTab === "admin" && } {activeTab === "admin-overview" && } {(activeTab === "admin-organizations" || activeTab === "admin-members") && } + {activeTab === "admin-policies" && } {activeTab === "admin-style-kits" && } {activeTab === "admin-models" && } {activeTab === "admin-queue" && } diff --git a/src/components/EnterprisePages.jsx b/src/components/EnterprisePages.jsx index 5cb2476..3f0bee3 100644 --- a/src/components/EnterprisePages.jsx +++ b/src/components/EnterprisePages.jsx @@ -121,11 +121,14 @@ import { exportOrganizationInvoicesCsv, fetchSystemUser, fetchSystemUsers, + evaluateOrganizationPolicies, + fetchOrganizationPolicies, resetSystemUserMfa, resetSystemUserPassword, revokeSystemUserSessions, updateOrganizationBilling, updateOrganizationEntitlement, + updateOrganizationPolicy, generateOrganizationInvoice, updateOrganizationInvoiceStatus, updateCostCenter, @@ -2572,6 +2575,246 @@ export function AdminStyleKitsPage({ platformContext, contextOverrides }) { ); } +const policyStatusLabels = { + draft: "草稿", + enforced: "强制", + monitor: "观察", + disabled: "停用" +}; + +const policyEnforcementLabels = { + block: "阻断", + approval: "审批", + warn: "预警", + off: "关闭" +}; + +const policySeverityLabels = { + critical: "关键", + high: "高", + medium: "中", + low: "低" +}; + +const policyCategoryLabels = { + model: "模型", + production: "生产", + voice: "声音", + delivery: "交付", + compliance: "合规", + security: "安全", + storage: "存储" +}; + +const policyResultLabels = { + pass: "通过", + warn: "预警", + approval_required: "需审批", + block: "阻断" +}; + +function policyResultStatus(result) { + if (result === "pass") return "approved"; + if (result === "block") return "blocked"; + if (result === "approval_required") return "needs-evidence"; + return "review"; +} + +function policyFormFromPolicy(policy = {}) { + return { + policyKey: policy.policyKey || "", + label: policy.label || "", + description: policy.description || "", + status: policy.status || "enforced", + enforcement: policy.enforcement || "block", + severity: policy.severity || "high", + approvalRequired: Boolean(policy.approvalRequired), + valueJson: JSON.stringify(policy.value || {}, null, 2), + appliesToJson: JSON.stringify(policy.appliesTo || [], null, 2) + }; +} + +function parsePolicyJson(value, label, array = false) { + try { + const parsed = JSON.parse(value || (array ? "[]" : "{}")); + if (array && !Array.isArray(parsed)) throw new Error("array"); + if (!array && (!parsed || typeof parsed !== "object" || Array.isArray(parsed))) throw new Error("object"); + return parsed; + } catch { + throw new Error(`${label} 不是合法 ${array ? "JSON 数组" : "JSON 对象"}`); + } +} + +export function AdminPolicyCenterPage({ platformContext, contextOverrides }) { + const permissions = new Set(platformContext?.context?.permissions || []); + const canManage = Boolean(platformContext?.context?.systemAdmin || permissions.has("policy:manage") || permissions.has("organization:manage")); + const organizationId = contextOverrides.organizationId || platformContext?.context?.currentOrganization?.id || ""; + const [data, setData] = useState({ policies: [], summary: {}, effective: {}, evaluations: [] }); + const [selectedKey, setSelectedKey] = useState(""); + const [form, setForm] = useState(policyFormFromPolicy()); + const [simulation, setSimulation] = useState({ + kind: "单画面关键帧", + prompt: "国漫 2D 动画风格,雨夜地铁口,一个完整单画面。", + costMode: "local", + endpoint: "http://127.0.0.1:8000/v1", + imageOutputCount: 1, + batchSize: 1, + randomNativeVoice: false, + rightsEvidenceApproved: true + }); + const [evaluation, setEvaluation] = useState(null); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(""); + const [notice, setNotice] = useState(""); + + async function load() { + if (!organizationId) return; + setLoading(true); + try { + const result = await fetchOrganizationPolicies(organizationId, contextOverrides); + setData(result); + const selected = result.policies.find((policy) => policy.policyKey === selectedKey) || result.policies[0]; + setSelectedKey(selected?.policyKey || ""); + setForm(policyFormFromPolicy(selected)); + setNotice(""); + } catch (error) { + setNotice(error.message); + } finally { + setLoading(false); + } + } + + useEffect(() => { load(); }, [organizationId]); + + const selected = data.policies.find((policy) => policy.policyKey === selectedKey) || null; + const recentBlocks = (data.evaluations || []).filter((item) => item.result === "block").length; + const effective = data.effective || {}; + + function selectPolicy(policy) { + setSelectedKey(policy.policyKey); + setForm(policyFormFromPolicy(policy)); + setNotice(""); + } + + async function savePolicy(event) { + event.preventDefault(); + if (!canManage || !selectedKey) return; + setBusy("save"); + try { + const body = { + label: form.label, + description: form.description, + status: form.status, + enforcement: form.enforcement, + severity: form.severity, + approvalRequired: form.approvalRequired, + value: parsePolicyJson(form.valueJson, "value"), + appliesTo: parsePolicyJson(form.appliesToJson, "appliesTo", true) + }; + const result = await updateOrganizationPolicy(organizationId, selectedKey, body, contextOverrides); + setData(result); + const saved = result.policies.find((policy) => policy.policyKey === selectedKey); + setForm(policyFormFromPolicy(saved)); + setNotice(`组织策略“${body.label}”已保存,并写入审计日志`); + } catch (error) { + setNotice(error.message); + } finally { + setBusy(""); + } + } + + async function runSimulation(event) { + event.preventDefault(); + setBusy("simulate"); + try { + const result = await evaluateOrganizationPolicies(organizationId, { + subjectType: "generation_request", + subjectId: `manual-${Date.now()}`, + kind: simulation.kind, + prompt: simulation.prompt, + costMode: simulation.costMode, + endpoint: simulation.endpoint, + imageOutputCount: Number(simulation.imageOutputCount || 1), + batchSize: Number(simulation.batchSize || 1), + randomNativeVoice: simulation.randomNativeVoice, + rightsEvidenceApproved: Boolean(simulation.rightsEvidenceApproved) + }, contextOverrides); + setEvaluation(result.evaluation); + setData((current) => ({ ...current, evaluations: result.evaluations || current.evaluations })); + setNotice(`策略模拟完成:${policyResultLabels[result.evaluation.result] || result.evaluation.result}`); + } catch (error) { + setNotice(error.message); + } finally { + setBusy(""); + } + } + + return ( +
+
} /> + {notice &&
{notice}
} +
+ + + + +
+
+
+ +
+ {data.policies.map((policy) => )} +
+
+
+ } /> +
+ + + + + + +