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
+1
View File
@@ -18,6 +18,7 @@
"smoke:identity": "node scripts/smoke-identity.mjs", "smoke:identity": "node scripts/smoke-identity.mjs",
"smoke:system-users": "node scripts/smoke-system-users.mjs", "smoke:system-users": "node scripts/smoke-system-users.mjs",
"smoke:commercial-governance": "node scripts/smoke-commercial-governance.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:invitations": "node scripts/smoke-invitations.mjs",
"smoke:commercial-ops": "node scripts/smoke-commercial-ops.mjs", "smoke:commercial-ops": "node scripts/smoke-commercial-ops.mjs",
"smoke:commercial-approvals": "node scripts/smoke-commercial-approvals.mjs", "smoke:commercial-approvals": "node scripts/smoke-commercial-approvals.mjs",
+1
View File
@@ -9,6 +9,7 @@ const scripts = [
"smoke:identity", "smoke:identity",
"smoke:system-users", "smoke:system-users",
"smoke:commercial-governance", "smoke:commercial-governance",
"smoke:organization-policies",
"smoke:invitations", "smoke:invitations",
"smoke:commercial-ops", "smoke:commercial-ops",
"smoke:commercial-approvals", "smoke:commercial-approvals",
+188
View File
@@ -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 });
}
}
+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_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_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_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_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_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)"); 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:manage", "修改组织设置"],
["organization:members:invite", "邀请组织成员"], ["organization:members:invite", "邀请组织成员"],
["organization:roles:manage", "管理组织角色权限策略"], ["organization:roles:manage", "管理组织角色权限策略"],
["policy:read", "查看组织生产、安全和合规策略"],
["policy:manage", "管理组织生产、安全和合规策略"],
["workspace:create", "创建工作区"], ["workspace:create", "创建工作区"],
["workspace:manage", "管理工作区设置"], ["workspace:manage", "管理工作区设置"],
["workspace:members:manage", "管理工作区成员"], ["workspace:members:manage", "管理工作区成员"],
@@ -285,14 +289,14 @@ function seedRoles() {
org_admin: [ org_admin: [
"organization:manage", "organization:members:invite", "workspace:create", "workspace:manage", "organization:manage", "organization:members:invite", "workspace:create", "workspace:manage",
"workspace:members:manage", "project:create", "project:manage", "project:members: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" "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"], 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", "style:read", "job:create"], 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", "style:read", "style:manage", "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", "style:read", "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", "style:read", "qa:review", "voice:approve", "delivery:view"], 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_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_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"] 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]); 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 = {}) { function commercialEntitlementDefaults(billing = {}) {
const clipLimit = Number(billing.monthly_clip_quota || 2400); const clipLimit = Number(billing.monthly_clip_quota || 2400);
const storageGb = Number(billing.storage_gb || 1024); 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()]); 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(); seedCommercialPlans();
seedOrganizationEntitlements(); seedOrganizationEntitlements();
rollQuotaPeriods();
seedCommercialApprovals(); seedCommercialApprovals();
seedMembersAndProjects(); seedMembersAndProjects();
seedModels(); seedModels();
+41 -5
View File
@@ -18,6 +18,7 @@ import { dispatchNotificationEvent } from "./notifications.mjs";
import { registerJobArtifacts } from "./media-artifacts.mjs"; import { registerJobArtifacts } from "./media-artifacts.mjs";
import { resolveKnowledgeContextForJob } from "./knowledge.mjs"; import { resolveKnowledgeContextForJob } from "./knowledge.mjs";
import { effectiveStyleKitForProject, styleKitForbiddenTerms, styleKitNegativeTerms } from "./style-kits.mjs"; import { effectiveStyleKitForProject, styleKitForbiddenTerms, styleKitNegativeTerms } from "./style-kits.mjs";
import { evaluateGenerationPolicy, recordPolicyEvaluation } from "./organization-policies.mjs";
const projectRoot = resolve(import.meta.dirname, ".."); const projectRoot = resolve(import.meta.dirname, "..");
const jobStorageRoot = resolve(projectRoot, "storage", "jobs"); 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 kind = String(body.kind || "自定义生成任务").trim();
const knowledge = resolveKnowledgeContextForJob(context, body); const knowledge = resolveKnowledgeContextForJob(context, body);
const styleKit = context.project ? effectiveStyleKitForProject(context, context.project.id).styleKit : null; 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 || {}) }; const inputs = { ...(body.inputs || {}) };
if (knowledge) { if (knowledge) {
inputs.knowledgeContext = knowledge.promptContext; inputs.knowledgeContext = knowledge.promptContext;
inputs.knowledgeCitations = knowledge.citations; inputs.knowledgeCitations = knowledge.citations;
inputs.knowledgeChunkIds = knowledge.chunkIds; inputs.knowledgeChunkIds = knowledge.chunkIds;
} }
const blockedTerms = styleKitForbiddenTerms(styleKit); const blockedTerms = [...new Set([...styleKitForbiddenTerms(styleKit), ...(organizationPolicy?.generation?.forbiddenTerms || [])])];
const negativeTerms = styleKitNegativeTerms(styleKit); const negativeTerms = [...new Set([...styleKitNegativeTerms(styleKit), ...(organizationPolicy?.generation?.forbiddenTerms || [])])];
// Negative prompts intentionally name the forbidden layouts; only inspect positive generation text here. // 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 promptText = [shot?.prompt, shot?.videoPrompt, shot?.action, shot?.camera, body.prompt].filter(Boolean).join(" ").toLowerCase();
const blocked = blockedTerms.filter((term) => promptText.includes(term.toLowerCase())); const blocked = blockedTerms.filter((term) => promptText.includes(term.toLowerCase()));
if (blocked.length) throw httpError(422, "single_frame_contract_violation", "请求包含禁止的一图多画面表达", { blocked }); 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))) { 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 }); 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 { return {
schema: "ai-drama.job.v1", schema: "ai-drama.job.v1",
createdAt: now(), createdAt: now(),
@@ -412,6 +430,14 @@ function buildContract(context, body, adapter, routing = null, preflight = {}) {
preflight, preflight,
knowledge, knowledge,
styleKit, styleKit,
organizationPolicy: policySnapshot,
policyEvaluation: organizationPolicyEvaluation ? {
result: organizationPolicyEvaluation.result,
blockers: organizationPolicyEvaluation.blockers,
approvals: organizationPolicyEvaluation.approvals,
warnings: organizationPolicyEvaluation.warnings,
checks: organizationPolicyEvaluation.checks
} : null,
shot, shot,
inputs, inputs,
constraints: { constraints: {
@@ -419,15 +445,15 @@ function buildContract(context, body, adapter, routing = null, preflight = {}) {
imageOutputCount: 1, imageOutputCount: 1,
batchSize: 1, batchSize: 1,
requireActualLastFrame: true, requireActualLastFrame: true,
localOnly: routing ? routing.policyMode === "local-only" || routing.connectorCostMode === "local" : adapter.costMode === "local", localOnly: currentCostMode === "local" && Boolean(organizationPolicy?.model?.defaultLocalOnly || (routing ? routing.policyMode === "local-only" : adapter.costMode === "local")),
approvalRequired: Boolean(routing?.requiresApproval || adapter.approvalRequired), approvalRequired: Boolean(routing?.requiresApproval || adapter.approvalRequired || organizationPolicyEvaluation?.approvals?.length),
blockedTerms, blockedTerms,
negativePromptTerms: negativeTerms, negativePromptTerms: negativeTerms,
styleEnforcement: styleKit?.source || "default-contract", styleEnforcement: styleKit?.source || "default-contract",
aspectRatio: styleKit?.deliverySpec?.aspectRatio || "9:16", aspectRatio: styleKit?.deliverySpec?.aspectRatio || "9:16",
resolution: styleKit?.deliverySpec?.resolution || "1080x1920", resolution: styleKit?.deliverySpec?.resolution || "1080x1920",
fps: styleKit?.deliverySpec?.fps || 24, fps: styleKit?.deliverySpec?.fps || 24,
voicePolicy: styleKit?.voicePolicy || null, voicePolicy: styleKit?.voicePolicy || organizationPolicy?.voice || null,
subtitlePreset: styleKit?.subtitlePreset || null subtitlePreset: styleKit?.subtitlePreset || null
} }
}; };
@@ -1045,6 +1071,16 @@ export async function createGenerationJob(context, body) {
} }
attachModelRouteApprovalToJob(context, approvalGrant, jobId); 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 } }); 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 } }); 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) }; 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 { backupSummary, createDatabaseBackup } from "./backup.mjs";
import { systemReadiness } from "./readiness.mjs"; import { systemReadiness } from "./readiness.mjs";
import { bindProjectStyleKit, createStyleKit, getProjectStyleKit, getStyleKit, listStyleKits, updateStyleKit } from "./style-kits.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 { dispatchNotificationEvent, listUserNotificationPreferences, listUserNotifications, markAllUserNotificationsRead, markUserNotificationRead, notificationDeliveries, updateUserNotificationPreference } from "./notifications.mjs";
import { composeProject, listCompositions } from "./composition.mjs"; import { composeProject, listCompositions } from "./composition.mjs";
import { listProjectArtifacts, readArtifactContent } from "./media-artifacts.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))); 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(?:\/([^/]+))?$/); const organizationRolePolicyMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/role-policies(?:\/([^/]+))?$/);
if (req.method === "GET" && organizationRolePolicyMatch) { if (req.method === "GET" && organizationRolePolicyMatch) {
const organizationId = decodeURIComponent(organizationRolePolicyMatch[1]); 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) 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 ( CREATE TABLE IF NOT EXISTS commercial_approval_requests (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, 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_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_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_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_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_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); 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 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 ( CREATE TABLE IF NOT EXISTS job_attempts (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
job_id TEXT NOT NULL REFERENCES generation_jobs(id) ON DELETE CASCADE, job_id TEXT NOT NULL REFERENCES generation_jobs(id) ON DELETE CASCADE,
+4 -1
View File
@@ -54,6 +54,7 @@ import {
AdminAuditPage, AdminAuditPage,
AdminModelsPage, AdminModelsPage,
AdminOverviewPage, AdminOverviewPage,
AdminPolicyCenterPage,
AdminStyleKitsPage, AdminStyleKitsPage,
AdminQueuePage, AdminQueuePage,
AdminUsagePage, AdminUsagePage,
@@ -151,6 +152,7 @@ const navigationGroups = [
{ id: "admin-overview", label: "管理概览", icon: Gauge, requiredPermission: "usage:view" }, { id: "admin-overview", label: "管理概览", icon: Gauge, requiredPermission: "usage:view" },
{ id: "admin-organizations", label: "组织与工作区", icon: Building2, requiredPermission: "organization:manage" }, { id: "admin-organizations", label: "组织与工作区", icon: Building2, requiredPermission: "organization:manage" },
{ id: "admin-members", label: "用户与权限", icon: Users, requiredPermission: "organization:members:invite" }, { 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-style-kits", label: "风格标准", icon: Palette, requiredAnyPermissions: ["style:read", "style:manage"] },
{ id: "admin-models", label: "模型与 Runner", icon: Network, requiredAnyPermissions: ["model:manage", "model:approve"] }, { id: "admin-models", label: "模型与 Runner", icon: Network, requiredAnyPermissions: ["model:manage", "model:approve"] },
{ id: "admin-queue", label: "队列与任务", icon: Wand2, requiredPermission: "queue:manage" }, { 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 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 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 canViewSystem = platformContext?.context?.systemAdmin || effectivePermissions.has("system:settings:view");
const canCreateProject = effectivePermissions.has("project:create"); const canCreateProject = effectivePermissions.has("project:create");
const canCreateJob = effectivePermissions.has("job:create"); const canCreateJob = effectivePermissions.has("job:create");
@@ -1891,6 +1893,7 @@ function App() {
{activeTab === "admin" && <AdminCenter platformContext={platformContext} onRefresh={refreshContext} onSwitchOrganization={switchOrganization} onInvite={inviteFromAdmin} onResendInvitation={resendInvitationFromAdmin} onRevokeInvitation={revokeInvitationFromAdmin} onCreateOrganization={createOrganizationFromAdmin} onCreateWorkspace={createWorkspaceFromAdmin} onCreateProject={createProjectFromAdmin} onUpdateOrganization={updateOrganizationFromAdmin} onUpdateWorkspace={updateWorkspaceFromAdmin} onUpdateProject={updateProjectFromAdmin} onUpdateOrganizationMember={updateOrganizationMemberFromAdmin} onUpdateOrganizationRolePolicy={updateOrganizationRolePolicyFromAdmin} onUpdateWorkspaceMember={updateWorkspaceMemberFromAdmin} onUpdateProjectMember={updateProjectMemberFromAdmin} onAddProjectMember={addProjectMemberFromAdmin} />} {activeTab === "admin" && <AdminCenter platformContext={platformContext} onRefresh={refreshContext} onSwitchOrganization={switchOrganization} onInvite={inviteFromAdmin} onResendInvitation={resendInvitationFromAdmin} onRevokeInvitation={revokeInvitationFromAdmin} onCreateOrganization={createOrganizationFromAdmin} onCreateWorkspace={createWorkspaceFromAdmin} onCreateProject={createProjectFromAdmin} onUpdateOrganization={updateOrganizationFromAdmin} onUpdateWorkspace={updateWorkspaceFromAdmin} onUpdateProject={updateProjectFromAdmin} onUpdateOrganizationMember={updateOrganizationMemberFromAdmin} onUpdateOrganizationRolePolicy={updateOrganizationRolePolicyFromAdmin} onUpdateWorkspaceMember={updateWorkspaceMemberFromAdmin} onUpdateProjectMember={updateProjectMemberFromAdmin} onAddProjectMember={addProjectMemberFromAdmin} />}
{activeTab === "admin-overview" && <AdminOverviewPage platformContext={platformContext} contextOverrides={contextOverrides} setActiveTab={setActiveTab} />} {activeTab === "admin-overview" && <AdminOverviewPage platformContext={platformContext} contextOverrides={contextOverrides} setActiveTab={setActiveTab} />}
{(activeTab === "admin-organizations" || activeTab === "admin-members") && <AdminCenter platformContext={platformContext} onRefresh={refreshContext} onSwitchOrganization={switchOrganization} onInvite={inviteFromAdmin} onResendInvitation={resendInvitationFromAdmin} onRevokeInvitation={revokeInvitationFromAdmin} onCreateOrganization={createOrganizationFromAdmin} onCreateWorkspace={createWorkspaceFromAdmin} onCreateProject={createProjectFromAdmin} onUpdateOrganization={updateOrganizationFromAdmin} onUpdateWorkspace={updateWorkspaceFromAdmin} onUpdateProject={updateProjectFromAdmin} onUpdateOrganizationMember={updateOrganizationMemberFromAdmin} onUpdateOrganizationRolePolicy={updateOrganizationRolePolicyFromAdmin} onUpdateWorkspaceMember={updateWorkspaceMemberFromAdmin} onUpdateProjectMember={updateProjectMemberFromAdmin} onAddProjectMember={addProjectMemberFromAdmin} />} {(activeTab === "admin-organizations" || activeTab === "admin-members") && <AdminCenter platformContext={platformContext} onRefresh={refreshContext} onSwitchOrganization={switchOrganization} onInvite={inviteFromAdmin} onResendInvitation={resendInvitationFromAdmin} onRevokeInvitation={revokeInvitationFromAdmin} onCreateOrganization={createOrganizationFromAdmin} onCreateWorkspace={createWorkspaceFromAdmin} onCreateProject={createProjectFromAdmin} onUpdateOrganization={updateOrganizationFromAdmin} onUpdateWorkspace={updateWorkspaceFromAdmin} onUpdateProject={updateProjectFromAdmin} onUpdateOrganizationMember={updateOrganizationMemberFromAdmin} onUpdateOrganizationRolePolicy={updateOrganizationRolePolicyFromAdmin} onUpdateWorkspaceMember={updateWorkspaceMemberFromAdmin} onUpdateProjectMember={updateProjectMemberFromAdmin} onAddProjectMember={addProjectMemberFromAdmin} />}
{activeTab === "admin-policies" && <AdminPolicyCenterPage platformContext={platformContext} contextOverrides={contextOverrides} />}
{activeTab === "admin-style-kits" && <AdminStyleKitsPage platformContext={platformContext} contextOverrides={contextOverrides} />} {activeTab === "admin-style-kits" && <AdminStyleKitsPage platformContext={platformContext} contextOverrides={contextOverrides} />}
{activeTab === "admin-models" && <AdminModelsPage platformContext={platformContext} contextOverrides={contextOverrides} onModelsChanged={syncModelRegistry} />} {activeTab === "admin-models" && <AdminModelsPage platformContext={platformContext} contextOverrides={contextOverrides} onModelsChanged={syncModelRegistry} />}
{activeTab === "admin-queue" && <AdminQueuePage platformContext={platformContext} contextOverrides={contextOverrides} />} {activeTab === "admin-queue" && <AdminQueuePage platformContext={platformContext} contextOverrides={contextOverrides} />}
+243
View File
@@ -121,11 +121,14 @@ import {
exportOrganizationInvoicesCsv, exportOrganizationInvoicesCsv,
fetchSystemUser, fetchSystemUser,
fetchSystemUsers, fetchSystemUsers,
evaluateOrganizationPolicies,
fetchOrganizationPolicies,
resetSystemUserMfa, resetSystemUserMfa,
resetSystemUserPassword, resetSystemUserPassword,
revokeSystemUserSessions, revokeSystemUserSessions,
updateOrganizationBilling, updateOrganizationBilling,
updateOrganizationEntitlement, updateOrganizationEntitlement,
updateOrganizationPolicy,
generateOrganizationInvoice, generateOrganizationInvoice,
updateOrganizationInvoiceStatus, updateOrganizationInvoiceStatus,
updateCostCenter, 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 (
<div className="enterprise-page org-policy-page">
<AdminHeader eyebrow="ADMIN CONSOLE / ORGANIZATION POLICIES" title="组织策略中心" description="按组织管理模型、生成、声音、交付、合规、安全和留存策略。策略会进入生成合同、评估记录和审计日志。" action={<div className="header-actions"><StatusBadge status={data.summary?.disabled ? "review" : "enforced"} label={`${data.summary?.enforced || 0} 项强制`} /><button className="subtle" onClick={load} disabled={loading}><RefreshCw size={15} />刷新</button></div>} />
{notice && <div className={`inline-notice ${notice.includes("不能") || notice.includes("不是") || notice.includes("阻断") ? "warn" : "ok"}`}><CheckCircle2 size={15} />{notice}</div>}
<div className="admin-kpi-grid compact-kpis">
<AdminKpi icon={ShieldCheck} label="策略总数" value={data.summary?.total || data.policies.length} detail={`${data.summary?.critical || 0} 项关键硬门`} tone="ok" />
<AdminKpi icon={ShieldAlert} label="审批策略" value={data.summary?.approval || 0} detail="外部模型与公网端点" tone="neutral" />
<AdminKpi icon={FileText} label="最近评估" value={(data.evaluations || []).length} detail={`${recentBlocks} 条阻断记录`} tone={recentBlocks ? "warn" : "ok"} />
<AdminKpi icon={Network} label="默认模型策略" value={effective.model?.defaultLocalOnly ? "Local" : "Mixed"} detail={(effective.model?.allowedCostModes || []).join(" / ")} tone="ok" />
</div>
<div className="org-policy-layout">
<section className="studio-card org-policy-list-card">
<SectionBar title="策略目录" detail={loading ? "读取中…" : `${data.policies.length} 项组织策略`} />
<div className="org-policy-list">
{data.policies.map((policy) => <button key={policy.policyKey} type="button" className={policy.policyKey === selectedKey ? "selected" : ""} onClick={() => selectPolicy(policy)}><div><strong>{policy.label}</strong><span>{policyCategoryLabels[policy.category] || policy.category} · {policyEnforcementLabels[policy.enforcement] || policy.enforcement}</span><small>{policy.description}</small></div><StatusBadge status={policy.status} label={policyStatusLabels[policy.status] || policy.status} /></button>)}
</div>
</section>
<section className="studio-card wide-card org-policy-editor-card">
<SectionBar title={selected ? `编辑:${selected.label}` : "编辑策略"} detail={canManage ? "保存后服务端立即执行" : "只读 · 需要 policy:manage"} action={<StatusBadge status={form.severity === "critical" ? "blocked" : "active"} label={policySeverityLabels[form.severity] || form.severity} />} />
<form className="org-policy-form" onSubmit={savePolicy}>
<label>策略 Key<input value={form.policyKey} readOnly /></label>
<label>名称<input value={form.label} disabled={!canManage} onChange={(event) => setForm({ ...form, label: event.target.value })} required /></label>
<label>状态<select value={form.status} disabled={!canManage} onChange={(event) => setForm({ ...form, status: event.target.value })}><option value="draft">草稿</option><option value="enforced">强制</option><option value="monitor">观察</option><option value="disabled">停用</option></select></label>
<label>执行方式<select value={form.enforcement} disabled={!canManage} onChange={(event) => setForm({ ...form, enforcement: event.target.value })}><option value="block">阻断</option><option value="approval">审批</option><option value="warn">预警</option><option value="off">关闭</option></select></label>
<label>级别<select value={form.severity} disabled={!canManage} onChange={(event) => setForm({ ...form, severity: event.target.value })}><option value="critical">关键</option><option value="high">高</option><option value="medium">中</option><option value="low">低</option></select></label>
<label className="checkbox-line"><input type="checkbox" checked={form.approvalRequired} disabled={!canManage} onChange={(event) => setForm({ ...form, approvalRequired: event.target.checked })} />需要审批记录</label>
<label className="org-policy-description">描述<textarea rows="3" disabled={!canManage} value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} /></label>
<label className="org-policy-json-field">value JSON<textarea rows="12" disabled={!canManage} value={form.valueJson} onChange={(event) => setForm({ ...form, valueJson: event.target.value })} /></label>
<label className="org-policy-json-field">appliesTo JSON<textarea rows="12" disabled={!canManage} value={form.appliesToJson} onChange={(event) => setForm({ ...form, appliesToJson: event.target.value })} /></label>
<div className="approval-policy-note"><ShieldCheck size={15} /><span>单画面、固定声线、外部连接器审批属于硬门:服务端会拒绝关闭或降级。</span></div>
<div className="form-actions"><button className="primary" type="submit" disabled={!canManage || busy === "save"}><Save size={15} />{busy === "save" ? "保存中…" : "保存策略"}</button></div>
</form>
</section>
</div>
<div className="enterprise-grid enterprise-grid-main org-policy-bottom-grid">
<section className="studio-card">
<SectionBar title="策略模拟" detail="写入评估 ledger" action={<SlidersHorizontal size={16} />} />
<form className="org-policy-simulator" onSubmit={runSimulation}>
<label>任务类型<input value={simulation.kind} onChange={(event) => setSimulation({ ...simulation, kind: event.target.value })} /></label>
<label>成本模式<select value={simulation.costMode} onChange={(event) => setSimulation({ ...simulation, costMode: event.target.value })}><option value="local">local</option><option value="mixed">mixed</option><option value="cloud">cloud</option></select></label>
<label>端点<input value={simulation.endpoint} onChange={(event) => setSimulation({ ...simulation, endpoint: event.target.value })} /></label>
<label>图片数量<input type="number" min="1" value={simulation.imageOutputCount} onChange={(event) => setSimulation({ ...simulation, imageOutputCount: event.target.value })} /></label>
<label>Batch Size<input type="number" min="1" value={simulation.batchSize} onChange={(event) => setSimulation({ ...simulation, batchSize: event.target.value })} /></label>
<label className="checkbox-line"><input type="checkbox" checked={simulation.randomNativeVoice} onChange={(event) => setSimulation({ ...simulation, randomNativeVoice: event.target.checked })} />随机原生声线</label>
<label className="checkbox-line"><input type="checkbox" checked={simulation.rightsEvidenceApproved} onChange={(event) => setSimulation({ ...simulation, rightsEvidenceApproved: event.target.checked })} />素材权利证据已通过</label>
<label className="org-policy-description">Prompt<textarea rows="4" value={simulation.prompt} onChange={(event) => setSimulation({ ...simulation, prompt: event.target.value })} /></label>
<button className="primary full-width" type="submit" disabled={busy === "simulate"}><Play size={15} />{busy === "simulate" ? "评估中…" : "运行策略评估"}</button>
</form>
</section>
<section className="studio-card wide-card">
<SectionBar title="评估结果" detail={evaluation ? new Date(evaluation.checkedAt).toLocaleString("zh-CN", { hour12: false }) : "等待模拟"} action={evaluation ? <StatusBadge status={policyResultStatus(evaluation.result)} label={policyResultLabels[evaluation.result] || evaluation.result} /> : null} />
{evaluation ? <div className="org-policy-evaluation"><div className="org-policy-check-grid">{evaluation.checks.map((check, index) => <div key={`${check.policyKey}-${index}`}><div><strong>{data.policies.find((policy) => policy.policyKey === check.policyKey)?.label || check.policyKey}</strong><StatusBadge status={policyResultStatus(check.result)} label={policyResultLabels[check.result] || check.result} /></div><span>{check.reason}</span><small>{JSON.stringify(check.evidence || {})}</small></div>)}</div><pre>{JSON.stringify({ result: evaluation.result, blockers: evaluation.blockers, approvals: evaluation.approvals, warnings: evaluation.warnings }, null, 2)}</pre></div> : <div className="empty-table">运行一次策略模拟后会显示每个政策的命中结果。</div>}
</section>
</div>
<section className="studio-card wide-card">
<SectionBar title="最近策略评估记录" detail={`${data.evaluations?.length || 0} 条`} />
<div className="org-policy-evaluation-table">
<div className="org-policy-evaluation-head"><span>时间</span><span>策略</span><span>对象</span><span>结果</span><span>原因</span></div>
{(data.evaluations || []).slice(0, 12).map((item) => <div className="org-policy-evaluation-row" key={item.id}><span>{item.createdAt?.replace("T", " ").slice(0, 19)}</span><strong>{data.policies.find((policy) => policy.policyKey === item.policyKey)?.label || item.policyKey}</strong><span>{item.subjectType}<small>{item.subjectId || "manual"}</small></span><StatusBadge status={policyResultStatus(item.result)} label={policyResultLabels[item.result] || item.result} /><small>{item.reason}</small></div>)}
{!(data.evaluations || []).length && <div className="empty-table">暂无策略评估记录。</div>}
</div>
</section>
</div>
);
}
const auditResultLabels = { const auditResultLabels = {
ok: "成功", ok: "成功",
pass: "通过", pass: "通过",
+12
View File
@@ -570,6 +570,18 @@ export async function updateCostCenter(organizationId, costCenterId, body, overr
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/cost-centers/${encodeURIComponent(costCenterId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides); return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/cost-centers/${encodeURIComponent(costCenterId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
} }
export async function fetchOrganizationPolicies(organizationId, overrides = {}) {
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/policies`, {}, overrides);
}
export async function updateOrganizationPolicy(organizationId, policyKey, body, overrides = {}) {
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/policies/${encodeURIComponent(policyKey)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
}
export async function evaluateOrganizationPolicies(organizationId, body, overrides = {}) {
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/policies/evaluate`, { method: "POST", body: JSON.stringify(body) }, overrides);
}
export async function updateIdentityPolicy(body, overrides = {}) { export async function updateIdentityPolicy(body, overrides = {}) {
return apiFetch("/api/system/identity/policy", { method: "PATCH", body: JSON.stringify(body) }, overrides); return apiFetch("/api/system/identity/policy", { method: "PATCH", body: JSON.stringify(body) }, overrides);
} }
+235
View File
@@ -7023,6 +7023,234 @@ body {
margin-top: 14px; margin-top: 14px;
} }
.org-policy-layout {
display: grid;
grid-template-columns: 360px minmax(0, 1fr);
gap: 14px;
align-items: start;
}
.org-policy-list-card {
position: sticky;
top: 14px;
}
.org-policy-list {
display: grid;
gap: 10px;
}
.org-policy-list button {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
width: 100%;
min-height: 88px;
padding: 10px 11px;
color: inherit;
background: #f8fbf8;
border: 1px solid #e0e7e2;
border-radius: 8px;
text-align: left;
}
.org-policy-list button.selected,
.org-policy-list button:hover {
background: #eef8f2;
border-color: #8fc7aa;
}
.org-policy-list strong {
display: block;
overflow: hidden;
color: var(--ink);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.org-policy-list span,
.org-policy-list small {
display: block;
overflow: hidden;
color: var(--faint);
font-size: 10px;
line-height: 1.45;
text-overflow: ellipsis;
white-space: nowrap;
}
.org-policy-form,
.org-policy-simulator {
display: grid;
gap: 12px;
}
.org-policy-form {
grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: end;
}
.org-policy-form label,
.org-policy-simulator label {
display: grid;
gap: 6px;
min-width: 0;
color: var(--muted);
font-size: 11px;
font-weight: 800;
}
.org-policy-form .checkbox-line,
.org-policy-simulator .checkbox-line {
display: flex;
gap: 8px;
align-items: center;
min-height: 36px;
padding: 8px 10px;
color: var(--ink);
background: #f8fbf8;
border: 1px solid var(--line);
border-radius: 7px;
}
.org-policy-form .checkbox-line input,
.org-policy-simulator .checkbox-line input {
width: 16px;
min-width: 16px;
height: 16px;
min-height: 16px;
padding: 0;
margin: 0;
accent-color: var(--accent);
}
.org-policy-form input,
.org-policy-form select,
.org-policy-form textarea,
.org-policy-simulator input,
.org-policy-simulator select,
.org-policy-simulator textarea {
width: 100%;
min-width: 0;
min-height: 36px;
padding: 8px 10px;
color: var(--ink);
background: #ffffff;
border: 1px solid var(--line);
border-radius: 7px;
outline: 0;
font: inherit;
font-size: 12px;
}
.org-policy-form textarea,
.org-policy-simulator textarea {
resize: vertical;
line-height: 1.5;
}
.org-policy-description,
.org-policy-form .approval-policy-note {
grid-column: 1 / -1;
}
.org-policy-json-field {
grid-column: span 3;
}
.org-policy-json-field textarea,
.org-policy-evaluation pre {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 10px;
line-height: 1.55;
}
.org-policy-json-field textarea {
min-height: 230px;
background: #fbfcfb;
}
.org-policy-bottom-grid {
margin-top: 14px;
}
.org-policy-check-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.org-policy-check-grid > div {
min-width: 0;
padding: 11px 12px;
background: #f7faf8;
border: 1px solid #e0e7e2;
border-radius: 8px;
}
.org-policy-check-grid > div > div {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
}
.org-policy-check-grid strong,
.org-policy-check-grid span,
.org-policy-check-grid small {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.org-policy-check-grid strong {
color: var(--ink);
font-size: 12px;
white-space: nowrap;
}
.org-policy-check-grid span {
margin-top: 6px;
color: var(--muted);
font-size: 11px;
line-height: 1.45;
}
.org-policy-check-grid small {
margin-top: 7px;
color: var(--faint);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 9px;
line-height: 1.45;
white-space: nowrap;
}
.org-policy-evaluation pre {
max-height: 320px;
margin: 12px 0 0;
overflow: auto;
padding: 12px;
color: #f5fbf5;
background: #101b17;
border-radius: 8px;
white-space: pre-wrap;
}
.org-policy-evaluation-table {
display: grid;
gap: 0;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 8px;
}
.org-policy-evaluation-head,
.org-policy-evaluation-row {
display: grid;
grid-template-columns: 150px minmax(150px, 1fr) minmax(140px, 0.8fr) 96px minmax(180px, 1.2fr);
gap: 10px;
align-items: center;
min-width: 0;
padding: 9px 11px;
}
.org-policy-evaluation-head {
color: var(--faint);
background: #f3f7f4;
font-size: 10px;
font-weight: 900;
text-transform: uppercase;
}
.org-policy-evaluation-row {
background: #ffffff;
border-top: 1px solid var(--line);
font-size: 11px;
}
.org-policy-evaluation-row > * {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.org-policy-evaluation-row small {
display: block;
color: var(--faint);
}
@media (max-width: 1180px) { @media (max-width: 1180px) {
.production-template-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } .production-template-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.production-three-column { grid-template-columns: repeat(2, minmax(0, 1fr)); } .production-three-column { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@@ -7050,12 +7278,17 @@ body {
.style-kit-layout, .style-kit-layout,
.style-kit-form, .style-kit-form,
.style-kit-contract-preview, .style-kit-contract-preview,
.org-policy-layout,
.org-policy-form,
.org-policy-check-grid,
.governance-form-grid, .governance-form-grid,
.knowledge-search-controls, .knowledge-search-controls,
.studio-queue-grid { grid-template-columns: 1fr; } .studio-queue-grid { grid-template-columns: 1fr; }
.model-resolution-wide { grid-column: auto; } .model-resolution-wide { grid-column: auto; }
.style-kit-list-card { position: static; } .style-kit-list-card { position: static; }
.style-kit-json-field { grid-column: auto; } .style-kit-json-field { grid-column: auto; }
.org-policy-list-card { position: static; }
.org-policy-json-field { grid-column: auto; }
.studio-banner, .studio-banner,
.studio-stage-head { flex-direction: column; } .studio-stage-head { flex-direction: column; }
.studio-banner-side { width: 100%; } .studio-banner-side { width: 100%; }
@@ -7095,6 +7328,8 @@ body {
.commercial-asset-inspector { max-height: none; } .commercial-asset-inspector { max-height: none; }
.asset-card-statuses { justify-content: flex-start; max-width: none; } .asset-card-statuses { justify-content: flex-start; max-width: none; }
.governance-action-row button { flex: 1 1 140px; justify-content: center; } .governance-action-row button { flex: 1 1 140px; justify-content: center; }
.org-policy-evaluation-head { display: none; }
.org-policy-evaluation-row { grid-template-columns: 1fr; align-items: start; }
.studio-queue-actions { flex-direction: column; } .studio-queue-actions { flex-direction: column; }
.studio-queue-actions button { width: 100%; justify-content: flex-start; } .studio-queue-actions button { width: 100%; justify-content: flex-start; }
.production-three-column, .production-three-column,