diff --git a/enterprise-training-policy-guard/README.md b/enterprise-training-policy-guard/README.md new file mode 100644 index 00000000..66da6083 --- /dev/null +++ b/enterprise-training-policy-guard/README.md @@ -0,0 +1,27 @@ +# Enterprise Training Policy Guard + +This module adds a focused Enterprise Tooling guard for training currency and policy acknowledgement evidence before privileged exports or project actions continue. + +It evaluates synthetic institutional access packets for: + +- missing required policy acknowledgements +- stale policy acknowledgements older than 180 days +- missing role-specific training modules +- expired training certificates +- missing receipt IDs for completed training + +The output is deterministic and reviewer-ready: `release`, `revise`, or `hold`, plus remediation text for each finding. + +## Run + +```bash +npm test +npm run demo +npm run demo:video +``` + +Generated artifacts are written to `reports/`. + +## Safety + +The sample packets are synthetic. The module does not call external services and does not use credentials, private research records, payment systems, wallets, live SSO/SCIM systems, or production exports. diff --git a/enterprise-training-policy-guard/demo.js b/enterprise-training-policy-guard/demo.js new file mode 100644 index 00000000..f400836c --- /dev/null +++ b/enterprise-training-policy-guard/demo.js @@ -0,0 +1,60 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { cleanPacket, riskyPacket } = require("./sample-data"); +const { evaluateTrainingPolicyPacket } = require("./index"); + +const reportsDir = path.join(__dirname, "reports"); +fs.mkdirSync(reportsDir, { recursive: true }); + +const release = evaluateTrainingPolicyPacket(cleanPacket); +const hold = evaluateTrainingPolicyPacket(riskyPacket); +const combined = { generatedAt: new Date().toISOString(), scenarios: [release, hold] }; + +fs.writeFileSync( + path.join(reportsDir, "training-policy-report.json"), + JSON.stringify(combined, null, 2) +); + +const markdown = [ + "# Enterprise Training Policy Guard Demo", + "", + `Generated: ${combined.generatedAt}`, + "", + "| Scenario | Decision | Blockers | Revisions | Summary |", + "| --- | --- | ---: | ---: | --- |", + ...combined.scenarios.map((item) => + `| ${item.actionId} | ${item.decision} | ${item.blockerCount} | ${item.revisionCount} | ${item.reviewerSummary} |` + ), + "", + "## Hold Findings", + "", + ...hold.findings.map((item) => + `- **${item.code}** (${item.severity}) for ${item.researcherId}: ${item.message} ${item.remediation}` + ) +].join("\n"); + +fs.writeFileSync(path.join(reportsDir, "training-policy-report.md"), markdown); + +const svg = ` + + + Enterprise Training Policy Guard + Stops privileged exports when training, policy acknowledgement, or evidence receipts are stale. + + Release + 2 researchers checked + 0 blockers / 0 revisions + + Hold + 4 blockers / 2 revisions + Missing, expired, and stale evidence + Synthetic data only. No live credentials, payment systems, or private research records are used. +`; + +fs.writeFileSync(path.join(reportsDir, "summary.svg"), svg); +fs.writeFileSync( + path.join(reportsDir, "demo-script.txt"), + "Show the release scenario, then the restricted export hold scenario, then the remediation list." +); + +console.log(JSON.stringify(combined, null, 2)); diff --git a/enterprise-training-policy-guard/demo_video.py b/enterprise-training-policy-guard/demo_video.py new file mode 100644 index 00000000..59873d53 --- /dev/null +++ b/enterprise-training-policy-guard/demo_video.py @@ -0,0 +1,88 @@ +import os +from pathlib import Path +import subprocess + +ROOT = Path(__file__).resolve().parent +REPORTS = ROOT / "reports" +FRAMES = REPORTS / "frames" + +REPORTS.mkdir(exist_ok=True) +FRAMES.mkdir(exist_ok=True) + +COLORS = [ + ("RELEASE", "0x16a34a", "All training and acknowledgements current"), + ("REVISE", "0xca8a04", "Receipt and acknowledgement gaps detected"), + ("HOLD", "0xdc2626", "Expired training blocks privileged export"), +] + +gif_path = REPORTS / "demo.gif" +mp4_path = REPORTS / "demo.mp4" + +ffmpeg = os.environ.get("FFMPEG") +if not ffmpeg: + candidates = list((ROOT.parents[2] / "tools" / "video-gen").glob("node_modules/**/ffmpeg.exe")) + ffmpeg = str(candidates[0]) if candidates else "ffmpeg" + +font = "C\\:/Windows/Fonts/arial.ttf" + +for index, (label, color, subtitle) in enumerate(COLORS): + frame_path = FRAMES / f"frame-{index:03d}.png" + filters = ( + "drawbox=x=60:y=56:w=780:h=408:color=0xf8fafc:t=fill," + "drawtext=fontfile='{font}':text='Enterprise Training Policy Guard':x=104:y=118:fontsize=34:fontcolor=0x0f172a," + "drawtext=fontfile='{font}':text='Decision\\: {label}':x=104:y=190:fontsize=30:fontcolor={color}," + "drawtext=fontfile='{font}':text='{subtitle}':x=104:y=248:fontsize=22:fontcolor=0x334155," + "drawbox=x=104:y=318:w={bar}:h=54:color={color}:t=fill," + "drawtext=fontfile='{font}':text='Synthetic data only - reviewer-ready findings':x=104:y=414:fontsize=18:fontcolor=0x475569" + ).format(font=font, label=label, subtitle=subtitle, color=color, bar=240 + index * 120) + subprocess.run( + [ + ffmpeg, + "-y", + "-f", + "lavfi", + "-i", + "color=c=0x0f172a:s=900x520:d=1", + "-vf", + filters, + "-frames:v", + "1", + str(frame_path), + ], + check=True, + ) + +subprocess.run( + [ + ffmpeg, + "-y", + "-framerate", + "1", + "-i", + str(FRAMES / "frame-%03d.png"), + "-vf", + "scale=900:520:flags=lanczos", + str(gif_path), + ], + check=True, +) + +subprocess.run( + [ + ffmpeg, + "-y", + "-framerate", + "1", + "-i", + str(FRAMES / "frame-%03d.png"), + "-vf", + "scale=900:520:flags=lanczos,format=yuv420p", + "-movflags", + "+faststart", + str(mp4_path), + ], + check=True, +) + +print(f"wrote {gif_path}") +print(f"wrote {mp4_path}") diff --git a/enterprise-training-policy-guard/index.js b/enterprise-training-policy-guard/index.js new file mode 100644 index 00000000..58658354 --- /dev/null +++ b/enterprise-training-policy-guard/index.js @@ -0,0 +1,145 @@ +const DAY_MS = 24 * 60 * 60 * 1000; + +const ROLE_TRAINING = { + principal_investigator: ["human-subjects", "secure-export", "ai-governance"], + data_steward: ["secure-export", "ai-governance"], + external_collaborator: ["human-subjects", "secure-export"] +}; + +function parseDate(value, field) { + const date = new Date(`${value}T00:00:00Z`); + if (Number.isNaN(date.getTime())) { + throw new Error(`Invalid date for ${field}: ${value}`); + } + return date; +} + +function daysBetween(a, b) { + return Math.floor((b.getTime() - a.getTime()) / DAY_MS); +} + +function buildFinding(code, severity, researcherId, message, remediation) { + return { code, severity, researcherId, message, remediation }; +} + +function requiredTrainingFor(researcher, action) { + const roleRequired = ROLE_TRAINING[researcher.role] || []; + return Array.from(new Set([...(action.requiredTraining || []), ...roleRequired])).sort(); +} + +function evaluateResearcher(researcher, action, now) { + const findings = []; + const policies = researcher.policies || {}; + const training = researcher.training || {}; + + for (const policy of action.requiredPolicies || []) { + if (!policies[policy]) { + findings.push(buildFinding( + "POLICY_ACK_MISSING", + "block", + researcher.id, + `${researcher.id} has not acknowledged required policy ${policy}.`, + `Collect ${policy} acknowledgement before ${action.type}.` + )); + continue; + } + + const acknowledgedAt = parseDate(policies[policy], `${researcher.id}.${policy}`); + if (daysBetween(acknowledgedAt, now) > 180) { + findings.push(buildFinding( + "POLICY_ACK_STALE", + "revise", + researcher.id, + `${researcher.id} acknowledged ${policy} more than 180 days ago.`, + `Refresh ${policy} acknowledgement and attach the receipt.` + )); + } + } + + for (const moduleName of requiredTrainingFor(researcher, action)) { + const record = training[moduleName]; + if (!record) { + findings.push(buildFinding( + "TRAINING_MISSING", + "block", + researcher.id, + `${researcher.id} is missing required training ${moduleName}.`, + `Complete ${moduleName} training before privileged access is released.` + )); + continue; + } + + const expiresAt = parseDate(record.expiresAt, `${researcher.id}.${moduleName}.expiresAt`); + if (expiresAt < now) { + findings.push(buildFinding( + "TRAINING_EXPIRED", + "block", + researcher.id, + `${researcher.id} has expired ${moduleName} training.`, + `Renew ${moduleName} training and upload a current certificate.` + )); + } + + if (!record.receiptId || String(record.receiptId).trim() === "") { + findings.push(buildFinding( + "TRAINING_RECEIPT_MISSING", + "revise", + researcher.id, + `${researcher.id} has ${moduleName} completion without evidence receipt.`, + `Attach a signed receipt for ${moduleName}.` + )); + } + } + + return findings; +} + +function summarizeDecision(findings) { + if (findings.some((item) => item.severity === "block")) { + return "hold"; + } + if (findings.some((item) => item.severity === "revise")) { + return "revise"; + } + return "release"; +} + +function evaluateTrainingPolicyPacket(packet) { + if (!packet || typeof packet !== "object") { + throw new Error("Packet must be an object."); + } + if (!packet.action || !Array.isArray(packet.researchers)) { + throw new Error("Packet requires action and researchers."); + } + + const now = new Date(packet.generatedAt || Date.now()); + if (Number.isNaN(now.getTime())) { + throw new Error("generatedAt must be a valid timestamp."); + } + + const findings = packet.researchers.flatMap((researcher) => + evaluateResearcher(researcher, packet.action, now) + ); + const decision = summarizeDecision(findings); + + return { + actionId: packet.action.id, + projectId: packet.action.projectId, + department: packet.action.department, + decision, + checkedResearchers: packet.researchers.length, + blockerCount: findings.filter((item) => item.severity === "block").length, + revisionCount: findings.filter((item) => item.severity === "revise").length, + findings, + reviewerSummary: decision === "release" + ? "All required enterprise training and policy acknowledgements are current." + : "Hold or revise before enterprise export/privileged access continues." + }; +} + +module.exports = { + ROLE_TRAINING, + evaluateTrainingPolicyPacket, + requiredTrainingFor, + summarizeDecision +}; diff --git a/enterprise-training-policy-guard/package.json b/enterprise-training-policy-guard/package.json new file mode 100644 index 00000000..abe88afc --- /dev/null +++ b/enterprise-training-policy-guard/package.json @@ -0,0 +1,13 @@ +{ + "name": "enterprise-training-policy-guard", + "version": "1.0.0", + "description": "Enterprise training and policy acknowledgement expiry guard for SCIBASE Enterprise Tooling", + "main": "index.js", + "type": "commonjs", + "scripts": { + "test": "node test.js", + "demo": "node demo.js", + "demo:video": "python demo_video.py" + }, + "license": "MIT" +} diff --git a/enterprise-training-policy-guard/reports/demo-script.txt b/enterprise-training-policy-guard/reports/demo-script.txt new file mode 100644 index 00000000..03e6b3a0 --- /dev/null +++ b/enterprise-training-policy-guard/reports/demo-script.txt @@ -0,0 +1 @@ +Show the release scenario, then the restricted export hold scenario, then the remediation list. \ No newline at end of file diff --git a/enterprise-training-policy-guard/reports/demo.gif b/enterprise-training-policy-guard/reports/demo.gif new file mode 100644 index 00000000..de57b227 Binary files /dev/null and b/enterprise-training-policy-guard/reports/demo.gif differ diff --git a/enterprise-training-policy-guard/reports/demo.mp4 b/enterprise-training-policy-guard/reports/demo.mp4 new file mode 100644 index 00000000..122bf48a Binary files /dev/null and b/enterprise-training-policy-guard/reports/demo.mp4 differ diff --git a/enterprise-training-policy-guard/reports/summary.svg b/enterprise-training-policy-guard/reports/summary.svg new file mode 100644 index 00000000..2c498252 --- /dev/null +++ b/enterprise-training-policy-guard/reports/summary.svg @@ -0,0 +1,15 @@ + + + + Enterprise Training Policy Guard + Stops privileged exports when training, policy acknowledgement, or evidence receipts are stale. + + Release + 2 researchers checked + 0 blockers / 0 revisions + + Hold + 4 blockers / 2 revisions + Missing, expired, and stale evidence + Synthetic data only. No live credentials, payment systems, or private research records are used. + \ No newline at end of file diff --git a/enterprise-training-policy-guard/reports/training-policy-report.json b/enterprise-training-policy-guard/reports/training-policy-report.json new file mode 100644 index 00000000..73f6cc62 --- /dev/null +++ b/enterprise-training-policy-guard/reports/training-policy-report.json @@ -0,0 +1,70 @@ +{ + "generatedAt": "2026-06-01T20:13:21.063Z", + "scenarios": [ + { + "actionId": "enterprise-export-001", + "projectId": "proj-open-clinical-reuse", + "department": "Clinical Informatics", + "decision": "release", + "checkedResearchers": 2, + "blockerCount": 0, + "revisionCount": 0, + "findings": [], + "reviewerSummary": "All required enterprise training and policy acknowledgements are current." + }, + { + "actionId": "enterprise-export-002", + "projectId": "proj-restricted-genomics", + "department": "Translational Genomics", + "decision": "hold", + "checkedResearchers": 2, + "blockerCount": 4, + "revisionCount": 2, + "findings": [ + { + "code": "POLICY_ACK_STALE", + "severity": "revise", + "researcherId": "u-200", + "message": "u-200 acknowledged data-use more than 180 days ago.", + "remediation": "Refresh data-use acknowledgement and attach the receipt." + }, + { + "code": "POLICY_ACK_MISSING", + "severity": "block", + "researcherId": "u-200", + "message": "u-200 has not acknowledged required policy ai-review.", + "remediation": "Collect ai-review acknowledgement before privileged_export." + }, + { + "code": "TRAINING_EXPIRED", + "severity": "block", + "researcherId": "u-200", + "message": "u-200 has expired human-subjects training.", + "remediation": "Renew human-subjects training and upload a current certificate." + }, + { + "code": "TRAINING_RECEIPT_MISSING", + "severity": "revise", + "researcherId": "u-200", + "message": "u-200 has secure-export completion without evidence receipt.", + "remediation": "Attach a signed receipt for secure-export." + }, + { + "code": "POLICY_ACK_MISSING", + "severity": "block", + "researcherId": "u-201", + "message": "u-201 has not acknowledged required policy export-control.", + "remediation": "Collect export-control acknowledgement before privileged_export." + }, + { + "code": "TRAINING_MISSING", + "severity": "block", + "researcherId": "u-201", + "message": "u-201 is missing required training secure-export.", + "remediation": "Complete secure-export training before privileged access is released." + } + ], + "reviewerSummary": "Hold or revise before enterprise export/privileged access continues." + } + ] +} \ No newline at end of file diff --git a/enterprise-training-policy-guard/reports/training-policy-report.md b/enterprise-training-policy-guard/reports/training-policy-report.md new file mode 100644 index 00000000..4075189f --- /dev/null +++ b/enterprise-training-policy-guard/reports/training-policy-report.md @@ -0,0 +1,17 @@ +# Enterprise Training Policy Guard Demo + +Generated: 2026-06-01T20:13:21.063Z + +| Scenario | Decision | Blockers | Revisions | Summary | +| --- | --- | ---: | ---: | --- | +| enterprise-export-001 | release | 0 | 0 | All required enterprise training and policy acknowledgements are current. | +| enterprise-export-002 | hold | 4 | 2 | Hold or revise before enterprise export/privileged access continues. | + +## Hold Findings + +- **POLICY_ACK_STALE** (revise) for u-200: u-200 acknowledged data-use more than 180 days ago. Refresh data-use acknowledgement and attach the receipt. +- **POLICY_ACK_MISSING** (block) for u-200: u-200 has not acknowledged required policy ai-review. Collect ai-review acknowledgement before privileged_export. +- **TRAINING_EXPIRED** (block) for u-200: u-200 has expired human-subjects training. Renew human-subjects training and upload a current certificate. +- **TRAINING_RECEIPT_MISSING** (revise) for u-200: u-200 has secure-export completion without evidence receipt. Attach a signed receipt for secure-export. +- **POLICY_ACK_MISSING** (block) for u-201: u-201 has not acknowledged required policy export-control. Collect export-control acknowledgement before privileged_export. +- **TRAINING_MISSING** (block) for u-201: u-201 is missing required training secure-export. Complete secure-export training before privileged access is released. \ No newline at end of file diff --git a/enterprise-training-policy-guard/requirements-map.md b/enterprise-training-policy-guard/requirements-map.md new file mode 100644 index 00000000..48ad9112 --- /dev/null +++ b/enterprise-training-policy-guard/requirements-map.md @@ -0,0 +1,12 @@ +# Requirements Map + +Issue #19 asks for Enterprise Tooling that gives institutions governance, compliance tracking, analytics, and integration controls. + +This slice covers a distinct compliance gate: + +- **Compliance tracking:** verifies policy acknowledgements and training receipts before privileged enterprise actions. +- **Governance controls:** produces `hold` decisions when certificates are expired or missing. +- **Admin review readiness:** emits blocker/revision counts, researcher IDs, and remediation messages. +- **Low-risk implementation:** dependency-free JavaScript, synthetic data only, no production integrations. + +Non-overlap: this is not another dashboard, webhook, SSO/SCIM, repository sync, API rate limit, vendor DPA, contract drift, legal hold, data residency, journal/style provenance, or admin audit slice. diff --git a/enterprise-training-policy-guard/sample-data.js b/enterprise-training-policy-guard/sample-data.js new file mode 100644 index 00000000..6fd68567 --- /dev/null +++ b/enterprise-training-policy-guard/sample-data.js @@ -0,0 +1,82 @@ +const cleanPacket = { + generatedAt: "2026-06-01T18:00:00Z", + action: { + id: "enterprise-export-001", + type: "privileged_export", + projectId: "proj-open-clinical-reuse", + department: "Clinical Informatics", + requiredPolicies: ["data-use", "export-control", "ai-review"], + requiredTraining: ["human-subjects", "secure-export", "ai-governance"] + }, + researchers: [ + { + id: "u-100", + role: "principal_investigator", + policies: { + "data-use": "2026-05-20", + "export-control": "2026-05-22", + "ai-review": "2026-05-25" + }, + training: { + "human-subjects": { completedAt: "2026-03-01", expiresAt: "2027-03-01", receiptId: "hs-100" }, + "secure-export": { completedAt: "2026-05-02", expiresAt: "2027-05-02", receiptId: "se-100" }, + "ai-governance": { completedAt: "2026-05-05", expiresAt: "2027-05-05", receiptId: "ai-100" } + } + }, + { + id: "u-101", + role: "data_steward", + policies: { + "data-use": "2026-05-18", + "export-control": "2026-05-18", + "ai-review": "2026-05-19" + }, + training: { + "human-subjects": { completedAt: "2026-02-11", expiresAt: "2027-02-11", receiptId: "hs-101" }, + "secure-export": { completedAt: "2026-04-08", expiresAt: "2027-04-08", receiptId: "se-101" }, + "ai-governance": { completedAt: "2026-04-09", expiresAt: "2027-04-09", receiptId: "ai-101" } + } + } + ] +}; + +const riskyPacket = { + generatedAt: "2026-06-01T18:00:00Z", + action: { + id: "enterprise-export-002", + type: "privileged_export", + projectId: "proj-restricted-genomics", + department: "Translational Genomics", + requiredPolicies: ["data-use", "export-control", "ai-review"], + requiredTraining: ["human-subjects", "secure-export", "ai-governance"] + }, + researchers: [ + { + id: "u-200", + role: "principal_investigator", + policies: { + "data-use": "2025-11-15", + "export-control": "2026-05-01" + }, + training: { + "human-subjects": { completedAt: "2025-01-15", expiresAt: "2026-01-15", receiptId: "hs-200" }, + "secure-export": { completedAt: "2026-03-04", expiresAt: "2027-03-04", receiptId: "" }, + "ai-governance": { completedAt: "2026-02-10", expiresAt: "2027-02-10", receiptId: "ai-200" } + } + }, + { + id: "u-201", + role: "external_collaborator", + policies: { + "data-use": "2026-05-15", + "ai-review": "2026-05-15" + }, + training: { + "human-subjects": { completedAt: "2026-05-10", expiresAt: "2027-05-10", receiptId: "hs-201" }, + "ai-governance": { completedAt: "2026-05-12", expiresAt: "2027-05-12", receiptId: "ai-201" } + } + } + ] +}; + +module.exports = { cleanPacket, riskyPacket }; diff --git a/enterprise-training-policy-guard/test.js b/enterprise-training-policy-guard/test.js new file mode 100644 index 00000000..8a8525cb --- /dev/null +++ b/enterprise-training-policy-guard/test.js @@ -0,0 +1,36 @@ +const assert = require("node:assert/strict"); +const { cleanPacket, riskyPacket } = require("./sample-data"); +const { + evaluateTrainingPolicyPacket, + requiredTrainingFor, + summarizeDecision +} = require("./index"); + +const clean = evaluateTrainingPolicyPacket(cleanPacket); +assert.equal(clean.decision, "release"); +assert.equal(clean.blockerCount, 0); +assert.equal(clean.revisionCount, 0); +assert.equal(clean.checkedResearchers, 2); + +const risky = evaluateTrainingPolicyPacket(riskyPacket); +assert.equal(risky.decision, "hold"); +assert.equal(risky.blockerCount, 4); +assert.equal(risky.revisionCount, 2); +assert.ok(risky.findings.some((item) => item.code === "TRAINING_EXPIRED")); +assert.ok(risky.findings.some((item) => item.code === "POLICY_ACK_MISSING")); +assert.ok(risky.findings.some((item) => item.code === "TRAINING_RECEIPT_MISSING")); +assert.ok(risky.findings.some((item) => item.researcherId === "u-201" && item.code === "TRAINING_MISSING")); + +assert.deepEqual( + requiredTrainingFor( + { id: "u-300", role: "external_collaborator", training: {}, policies: {} }, + { requiredTraining: ["ai-governance"] } + ), + ["ai-governance", "human-subjects", "secure-export"] +); + +assert.equal(summarizeDecision([{ severity: "revise" }]), "revise"); +assert.equal(summarizeDecision([{ severity: "block" }, { severity: "revise" }]), "hold"); +assert.throws(() => evaluateTrainingPolicyPacket({ generatedAt: "not-a-date", action: {}, researchers: [] })); + +console.log("enterprise training policy guard tests passed");