From ee2ec1f5303f2488cb180a008d6a1c14483ada29 Mon Sep 17 00:00:00 2001 From: "tho.nguyen" <91511523+haki203@users.noreply.github.com> Date: Fri, 22 May 2026 21:18:00 +0700 Subject: [PATCH] Add enterprise LMS roster passback guard --- .../README.md | 32 +++ enterprise-lms-roster-passback-guard/demo.js | 59 +++++ enterprise-lms-roster-passback-guard/index.js | 220 ++++++++++++++++++ .../reports/demo.mp4 | Bin 0 -> 27773 bytes .../reports/lms-sync-packet.json | 156 +++++++++++++ .../reports/lms-sync-report.md | 102 ++++++++ .../reports/summary.svg | 28 +++ .../sample-data.js | 67 ++++++ enterprise-lms-roster-passback-guard/test.js | 106 +++++++++ 9 files changed, 770 insertions(+) create mode 100644 enterprise-lms-roster-passback-guard/README.md create mode 100644 enterprise-lms-roster-passback-guard/demo.js create mode 100644 enterprise-lms-roster-passback-guard/index.js create mode 100644 enterprise-lms-roster-passback-guard/reports/demo.mp4 create mode 100644 enterprise-lms-roster-passback-guard/reports/lms-sync-packet.json create mode 100644 enterprise-lms-roster-passback-guard/reports/lms-sync-report.md create mode 100644 enterprise-lms-roster-passback-guard/reports/summary.svg create mode 100644 enterprise-lms-roster-passback-guard/sample-data.js create mode 100644 enterprise-lms-roster-passback-guard/test.js diff --git a/enterprise-lms-roster-passback-guard/README.md b/enterprise-lms-roster-passback-guard/README.md new file mode 100644 index 00000000..093ace47 --- /dev/null +++ b/enterprise-lms-roster-passback-guard/README.md @@ -0,0 +1,32 @@ +# Enterprise LMS Roster Passback Guard + +Self-contained Enterprise Tooling slice for issue #19. It validates Canvas/Moodle roster and grade-passback sync evidence before an institution enables LMS integration events. + +## What It Checks + +- Course-section enrollment stays inside approved enterprise sync scope. +- Dropped enrollment state is fresh enough for roster export. +- ORCID/profile linkage exists for enterprise reporting. +- Student roster sync consent is present before FERPA-sensitive data moves. +- Grade passback events occur inside the approved release window. +- Grade and roster events have acknowledged webhook receipts. + +## Outputs + +- `reports/lms-sync-packet.json`: structured reviewer decisions and findings. +- `reports/lms-sync-report.md`: readable report for each synthetic LMS sync scenario. +- `reports/summary.svg`: visual summary of approve, review, hold, and block decisions. +- `reports/demo.mp4`: short demo artifact for Algora review. + +## Local Verification + +```bash +node enterprise-lms-roster-passback-guard/test.js +node enterprise-lms-roster-passback-guard/demo.js +node --check enterprise-lms-roster-passback-guard/index.js +node --check enterprise-lms-roster-passback-guard/test.js +node --check enterprise-lms-roster-passback-guard/demo.js +node --check enterprise-lms-roster-passback-guard/sample-data.js +``` + +The module is dependency-free, uses synthetic data only, and makes no network calls. diff --git a/enterprise-lms-roster-passback-guard/demo.js b/enterprise-lms-roster-passback-guard/demo.js new file mode 100644 index 00000000..9f576ae0 --- /dev/null +++ b/enterprise-lms-roster-passback-guard/demo.js @@ -0,0 +1,59 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +const {evaluateLmsSyncGuard, buildLmsSyncReport} = require('./index'); +const {scenarios} = require('./sample-data'); + +const reportsDir = path.join(__dirname, 'reports'); +fs.mkdirSync(reportsDir, {recursive: true}); + +const evaluations = scenarios.map((scenario) => ({ + scenario: scenario.name, + ...evaluateLmsSyncGuard(scenario), +})); + +const packetJson = JSON.stringify(evaluations, null, 2); +const reviewerReport = evaluations.map(buildLmsSyncReport).join('\n---\n'); +const approved = evaluations.filter((item) => item.decision === 'approve-sync').length; +const review = evaluations.filter((item) => item.decision === 'needs-admin-review').length; +const passback = evaluations.filter((item) => item.decision === 'hold-passback').length; +const blocked = evaluations.filter((item) => item.decision === 'block-sync').length; +const findings = evaluations.reduce((sum, item) => sum + item.findings.length, 0); + +const svg = ` + + Enterprise LMS Roster Passback Guard + Synthetic Canvas/Moodle roster and grade sync safety packet + + + Approve + ${approved} + + + + Admin Review + ${review} + + + + Passback Hold + ${passback} + + + + Block + ${blocked} + + Checks: approved sections, FERPA consent, ORCID linkage, release windows, webhook receipts + Reviewer findings: ${findings}. Outputs: JSON packet, Markdown report, SVG summary, MP4 artifact. + Synthetic data only. No live LMS calls, student records, credentials, or external providers. + +`; + +fs.writeFileSync(path.join(reportsDir, 'lms-sync-packet.json'), `${packetJson}\n`); +fs.writeFileSync(path.join(reportsDir, 'lms-sync-report.md'), reviewerReport); +fs.writeFileSync(path.join(reportsDir, 'summary.svg'), svg); + +console.log(`Wrote ${evaluations.length} LMS sync evaluations to ${reportsDir}`); +console.log(`Decision counts: approved=${approved}, review=${review}, passback=${passback}, blocked=${blocked}`); +console.log(`Reviewer findings: ${findings}`); diff --git a/enterprise-lms-roster-passback-guard/index.js b/enterprise-lms-roster-passback-guard/index.js new file mode 100644 index 00000000..bfcef054 --- /dev/null +++ b/enterprise-lms-roster-passback-guard/index.js @@ -0,0 +1,220 @@ +const staleDropDays = 14; + +function normalizeList(value) { + return Array.isArray(value) ? value : []; +} + +function guardAction(type, target, reason) { + return {type, target, reason}; +} + +function daysBetween(start, end) { + return Math.floor((Date.parse(end) - Date.parse(start)) / 86400000); +} + +function isInsideWindow(timestamp, window) { + if (!timestamp || !window) { + return false; + } + return Date.parse(timestamp) >= Date.parse(window.opensAt) && Date.parse(timestamp) <= Date.parse(window.closesAt); +} + +function countByType(findings, type) { + return findings.filter((finding) => finding.type === type).length; +} + +function severityCounts(findings) { + return findings.reduce((counts, finding) => { + counts[finding.severity] = (counts[finding.severity] || 0) + 1; + return counts; + }, {}); +} + +function evaluateLmsSyncGuard(input) { + const roster = normalizeList(input.roster); + const passbackEvents = normalizeList(input.passbackEvents); + const webhookReceipts = normalizeList(input.webhookReceipts); + const approvedSections = new Set(normalizeList(input.approvedSections)); + const receiptByEvent = new Map(webhookReceipts.map((receipt) => [receipt.eventId, receipt])); + const findings = []; + const requiredActions = []; + + for (const enrollment of roster) { + if (!approvedSections.has(enrollment.sectionId)) { + findings.push({ + type: 'out-of-scope-section', + severity: 'critical', + userId: enrollment.userId, + sectionId: enrollment.sectionId, + message: `${enrollment.userId} is enrolled in unapproved section ${enrollment.sectionId}`, + }); + requiredActions.push(guardAction( + 'remove_out_of_scope_enrollment', + enrollment.userId, + 'LMS sync must stay inside approved institutional course sections' + )); + } + + if (enrollment.status === 'dropped' && enrollment.lastChangedAt && daysBetween(enrollment.lastChangedAt, input.generatedAt) > staleDropDays) { + findings.push({ + type: 'stale-dropped-enrollment', + severity: 'major', + userId: enrollment.userId, + lastChangedAt: enrollment.lastChangedAt, + message: `${enrollment.userId} has stale dropped enrollment state from ${enrollment.lastChangedAt}`, + }); + requiredActions.push(guardAction( + 'confirm_dropped_enrollment', + enrollment.userId, + 'stale dropped students need admin confirmation before roster sync' + )); + } + + if (!enrollment.orcid) { + findings.push({ + type: 'missing-orcid-linkage', + severity: 'major', + userId: enrollment.userId, + message: `${enrollment.userId} lacks ORCID/profile linkage for enterprise reporting`, + }); + requiredActions.push(guardAction( + 'link_orcid_profile', + enrollment.userId, + 'enterprise reporting needs stable researcher identity linkage' + )); + } + + if (!enrollment.consent) { + findings.push({ + type: 'missing-ferpa-consent', + severity: 'critical', + userId: enrollment.userId, + message: `${enrollment.userId} lacks FERPA-safe sync consent`, + }); + requiredActions.push(guardAction( + 'confirm_lms_sync_consent', + enrollment.userId, + 'student roster data cannot sync without consent evidence' + )); + } + } + + for (const event of passbackEvents) { + if (!approvedSections.has(event.sectionId)) { + findings.push({ + type: 'passback-section-out-of-scope', + severity: 'critical', + eventId: event.eventId, + sectionId: event.sectionId, + message: `${event.eventId} targets unapproved section ${event.sectionId}`, + }); + requiredActions.push(guardAction( + 'block_passback_event', + event.eventId, + 'grade passback section must be approved for this institution' + )); + } + + if (!isInsideWindow(event.releasedAt, input.gradeReleaseWindow)) { + findings.push({ + type: 'grade-release-window-violation', + severity: 'major', + eventId: event.eventId, + releasedAt: event.releasedAt, + message: `${event.eventId} released outside configured grade window`, + }); + requiredActions.push(guardAction( + 'hold_grade_passback', + event.eventId, + 'grade releases must occur inside the approved LMS release window' + )); + } + + const receipt = receiptByEvent.get(event.eventId); + if (!receipt || !receipt.acknowledged) { + findings.push({ + type: 'missing-webhook-acknowledgement', + severity: 'major', + eventId: event.eventId, + message: `${event.eventId} lacks acknowledged LMS webhook receipt`, + }); + requiredActions.push(guardAction( + 'replay_or_confirm_webhook', + event.eventId, + 'grade passback must have an acknowledged LMS webhook receipt' + )); + } + } + + const counts = severityCounts(findings); + const criticalCount = counts.critical || 0; + const majorCount = counts.major || 0; + const decision = criticalCount > 0 + ? 'block-sync' + : findings.some((finding) => finding.type.includes('passback') || finding.type.includes('grade') || finding.type.includes('webhook')) + ? 'hold-passback' + : majorCount > 0 + ? 'needs-admin-review' + : 'approve-sync'; + const syncReadinessScore = Math.max(0, 100 - criticalCount * 40 - majorCount * 20); + + return { + syncId: input.syncId, + generatedAt: input.generatedAt, + institution: input.institution, + decision, + syncReadinessScore, + findings, + requiredActions, + summary: { + rosterCount: roster.length, + passbackEventCount: passbackEvents.length, + webhookReceiptCount: webhookReceipts.length, + approvedSectionCount: approvedSections.size, + outOfScopeEnrollments: countByType(findings, 'out-of-scope-section'), + staleDrops: countByType(findings, 'stale-dropped-enrollment'), + missingOrcidLinks: countByType(findings, 'missing-orcid-linkage'), + passbackHolds: findings.filter((finding) => finding.type.includes('passback') || finding.type.includes('grade') || finding.type.includes('webhook')).length, + severityCounts: counts, + }, + }; +} + +function buildLmsSyncReport(result) { + const lines = [ + '# Enterprise LMS Roster Passback Guard Report', + '', + `Sync: ${result.syncId}`, + `Institution: ${result.institution}`, + `Generated: ${result.generatedAt}`, + `Decision: ${result.decision}`, + `Sync readiness score: ${result.syncReadinessScore}`, + '', + '## Summary', + '', + `Roster entries: ${result.summary.rosterCount}`, + `Passback events: ${result.summary.passbackEventCount}`, + `Webhook receipts: ${result.summary.webhookReceiptCount}`, + `Approved sections: ${result.summary.approvedSectionCount}`, + `Findings: ${result.findings.length}`, + '', + '## Findings', + '', + ...(result.findings.length + ? result.findings.map((finding) => `- ${finding.severity}: ${finding.type} - ${finding.message}`) + : ['- None']), + '', + '## Required Actions', + '', + ...(result.requiredActions.length + ? result.requiredActions.map((action) => `- ${action.type}: ${action.target} (${action.reason})`) + : ['- None']), + '', + ]; + return lines.join('\n'); +} + +module.exports = { + evaluateLmsSyncGuard, + buildLmsSyncReport, +}; diff --git a/enterprise-lms-roster-passback-guard/reports/demo.mp4 b/enterprise-lms-roster-passback-guard/reports/demo.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..0cf3b4168a0ec78f20fbe2dda178708db7c384e7 GIT binary patch literal 27773 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

Q3FZFL<*gSG(SZ#`SM-J%6nnoTpyc9kNo* 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 V)F?o~$i + + Enterprise LMS Roster Passback Guard + Synthetic Canvas/Moodle roster and grade sync safety packet + + + Approve + 1 + + + + Admin Review + 1 + + + + Passback Hold + 1 + + + + Block + 1 + + Checks: approved sections, FERPA consent, ORCID linkage, release windows, webhook receipts + Reviewer findings: 5. Outputs: JSON packet, Markdown report, SVG summary, MP4 artifact. + Synthetic data only. No live LMS calls, student records, credentials, or external providers. + diff --git a/enterprise-lms-roster-passback-guard/sample-data.js b/enterprise-lms-roster-passback-guard/sample-data.js new file mode 100644 index 00000000..bada2b5a --- /dev/null +++ b/enterprise-lms-roster-passback-guard/sample-data.js @@ -0,0 +1,67 @@ +const scenarios = [ + { + name: 'out-of-scope-section-block', + syncId: 'sync-canvas-biology', + generatedAt: '2026-05-22T14:40:00Z', + institution: 'North Valley University', + approvedSections: ['BIO-501-A', 'BIO-501-B'], + gradeReleaseWindow: {opensAt: '2026-05-20T00:00:00Z', closesAt: '2026-05-30T00:00:00Z'}, + roster: [ + {userId: 'u-ada', role: 'student', sectionId: 'BIO-501-A', status: 'active', orcid: '0000-0001', consent: true}, + {userId: 'u-ben', role: 'student', sectionId: 'CHEM-999-Z', status: 'active', orcid: '0000-0002', consent: true}, + ], + passbackEvents: [], + webhookReceipts: [{eventId: 'roster-1', acknowledged: true, receivedAt: '2026-05-22T14:41:00Z'}], + }, + { + name: 'admin-review-roster-drift', + syncId: 'sync-moodle-physics', + generatedAt: '2026-05-22T14:40:00Z', + institution: 'North Valley University', + approvedSections: ['PHYS-610-A'], + gradeReleaseWindow: {opensAt: '2026-05-20T00:00:00Z', closesAt: '2026-05-30T00:00:00Z'}, + roster: [ + {userId: 'u-clio', role: 'student', sectionId: 'PHYS-610-A', status: 'dropped', lastChangedAt: '2026-05-01T00:00:00Z', orcid: '0000-0003', consent: true}, + {userId: 'u-drew', role: 'instructor', sectionId: 'PHYS-610-A', status: 'active', lastChangedAt: '2026-05-22T00:00:00Z', orcid: '', consent: true}, + ], + passbackEvents: [], + webhookReceipts: [{eventId: 'roster-2', acknowledged: true, receivedAt: '2026-05-22T14:41:00Z'}], + }, + { + name: 'passback-hold', + syncId: 'sync-grade-passback', + generatedAt: '2026-05-22T14:40:00Z', + institution: 'North Valley University', + approvedSections: ['CS-720-A'], + gradeReleaseWindow: {opensAt: '2026-05-20T00:00:00Z', closesAt: '2026-05-21T00:00:00Z'}, + roster: [ + {userId: 'u-erin', role: 'student', sectionId: 'CS-720-A', status: 'active', lastChangedAt: '2026-05-22T00:00:00Z', orcid: '0000-0004', consent: true}, + ], + passbackEvents: [ + {eventId: 'grade-1', userId: 'u-erin', sectionId: 'CS-720-A', score: 94, releasedAt: '2026-05-22T14:00:00Z'}, + {eventId: 'grade-2', userId: 'u-erin', sectionId: 'CS-720-A', score: 88, releasedAt: '2026-05-20T14:00:00Z'}, + ], + webhookReceipts: [{eventId: 'grade-1', acknowledged: true, receivedAt: '2026-05-22T14:01:00Z'}], + }, + { + name: 'clean-lms-sync', + syncId: 'sync-clean-lms', + generatedAt: '2026-05-22T14:40:00Z', + institution: 'North Valley University', + approvedSections: ['BIO-501-A'], + gradeReleaseWindow: {opensAt: '2026-05-20T00:00:00Z', closesAt: '2026-05-30T00:00:00Z'}, + roster: [ + {userId: 'u-fox', role: 'student', sectionId: 'BIO-501-A', status: 'active', lastChangedAt: '2026-05-22T00:00:00Z', orcid: '0000-0005', consent: true}, + {userId: 'u-gia', role: 'instructor', sectionId: 'BIO-501-A', status: 'active', lastChangedAt: '2026-05-22T00:00:00Z', orcid: '0000-0006', consent: true}, + ], + passbackEvents: [ + {eventId: 'grade-clean', userId: 'u-fox', sectionId: 'BIO-501-A', score: 91, releasedAt: '2026-05-22T14:00:00Z'}, + ], + webhookReceipts: [ + {eventId: 'grade-clean', acknowledged: true, receivedAt: '2026-05-22T14:01:00Z'}, + {eventId: 'roster-clean', acknowledged: true, receivedAt: '2026-05-22T14:02:00Z'}, + ], + }, +]; + +module.exports = {scenarios}; diff --git a/enterprise-lms-roster-passback-guard/test.js b/enterprise-lms-roster-passback-guard/test.js new file mode 100644 index 00000000..7fce0f12 --- /dev/null +++ b/enterprise-lms-roster-passback-guard/test.js @@ -0,0 +1,106 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + evaluateLmsSyncGuard, + buildLmsSyncReport, +} = require('./index'); + +test('blocks LMS sync when enrollment section is outside approved enterprise scope', () => { + const result = evaluateLmsSyncGuard({ + syncId: 'sync-canvas-biology', + generatedAt: '2026-05-22T14:40:00Z', + institution: 'North Valley University', + approvedSections: ['BIO-501-A', 'BIO-501-B'], + gradeReleaseWindow: {opensAt: '2026-05-20T00:00:00Z', closesAt: '2026-05-30T00:00:00Z'}, + roster: [ + {userId: 'u-ada', role: 'student', sectionId: 'BIO-501-A', status: 'active', orcid: '0000-0001', consent: true}, + {userId: 'u-ben', role: 'student', sectionId: 'CHEM-999-Z', status: 'active', orcid: '0000-0002', consent: true}, + ], + passbackEvents: [], + webhookReceipts: [{eventId: 'roster-1', acknowledged: true, receivedAt: '2026-05-22T14:41:00Z'}], + }); + + assert.equal(result.decision, 'block-sync'); + assert.equal(result.findings[0].type, 'out-of-scope-section'); + assert.equal(result.findings[0].userId, 'u-ben'); + assert.equal(result.summary.outOfScopeEnrollments, 1); +}); + +test('requires admin review for stale enrollment drops and missing ORCID linkage', () => { + const result = evaluateLmsSyncGuard({ + syncId: 'sync-moodle-physics', + generatedAt: '2026-05-22T14:40:00Z', + institution: 'North Valley University', + approvedSections: ['PHYS-610-A'], + gradeReleaseWindow: {opensAt: '2026-05-20T00:00:00Z', closesAt: '2026-05-30T00:00:00Z'}, + roster: [ + {userId: 'u-clio', role: 'student', sectionId: 'PHYS-610-A', status: 'dropped', lastChangedAt: '2026-05-01T00:00:00Z', orcid: '0000-0003', consent: true}, + {userId: 'u-drew', role: 'instructor', sectionId: 'PHYS-610-A', status: 'active', lastChangedAt: '2026-05-22T00:00:00Z', orcid: '', consent: true}, + ], + passbackEvents: [], + webhookReceipts: [{eventId: 'roster-2', acknowledged: true, receivedAt: '2026-05-22T14:41:00Z'}], + }); + + assert.equal(result.decision, 'needs-admin-review'); + assert.deepEqual( + result.findings.map((finding) => finding.type), + ['stale-dropped-enrollment', 'missing-orcid-linkage'] + ); + assert.equal(result.requiredActions.length, 2); +}); + +test('holds grade passback outside release window or without webhook acknowledgement', () => { + const result = evaluateLmsSyncGuard({ + syncId: 'sync-grade-passback', + generatedAt: '2026-05-22T14:40:00Z', + institution: 'North Valley University', + approvedSections: ['CS-720-A'], + gradeReleaseWindow: {opensAt: '2026-05-20T00:00:00Z', closesAt: '2026-05-21T00:00:00Z'}, + roster: [ + {userId: 'u-erin', role: 'student', sectionId: 'CS-720-A', status: 'active', lastChangedAt: '2026-05-22T00:00:00Z', orcid: '0000-0004', consent: true}, + ], + passbackEvents: [ + {eventId: 'grade-1', userId: 'u-erin', sectionId: 'CS-720-A', score: 94, releasedAt: '2026-05-22T14:00:00Z'}, + {eventId: 'grade-2', userId: 'u-erin', sectionId: 'CS-720-A', score: 88, releasedAt: '2026-05-20T14:00:00Z'}, + ], + webhookReceipts: [{eventId: 'grade-1', acknowledged: true, receivedAt: '2026-05-22T14:01:00Z'}], + }); + + assert.equal(result.decision, 'hold-passback'); + assert.deepEqual( + result.findings.map((finding) => finding.type), + ['grade-release-window-violation', 'missing-webhook-acknowledgement'] + ); +}); + +test('approves clean LMS roster and grade passback packet with deterministic report', () => { + const result = evaluateLmsSyncGuard({ + syncId: 'sync-clean-lms', + generatedAt: '2026-05-22T14:40:00Z', + institution: 'North Valley University', + approvedSections: ['BIO-501-A'], + gradeReleaseWindow: {opensAt: '2026-05-20T00:00:00Z', closesAt: '2026-05-30T00:00:00Z'}, + roster: [ + {userId: 'u-fox', role: 'student', sectionId: 'BIO-501-A', status: 'active', lastChangedAt: '2026-05-22T00:00:00Z', orcid: '0000-0005', consent: true}, + {userId: 'u-gia', role: 'instructor', sectionId: 'BIO-501-A', status: 'active', lastChangedAt: '2026-05-22T00:00:00Z', orcid: '0000-0006', consent: true}, + ], + passbackEvents: [ + {eventId: 'grade-clean', userId: 'u-fox', sectionId: 'BIO-501-A', score: 91, releasedAt: '2026-05-22T14:00:00Z'}, + ], + webhookReceipts: [ + {eventId: 'grade-clean', acknowledged: true, receivedAt: '2026-05-22T14:01:00Z'}, + {eventId: 'roster-clean', acknowledged: true, receivedAt: '2026-05-22T14:02:00Z'}, + ], + }); + + assert.equal(result.decision, 'approve-sync'); + assert.equal(result.findings.length, 0); + assert.equal(result.syncReadinessScore, 100); + + const report = buildLmsSyncReport(result); + assert.match(report, /# Enterprise LMS Roster Passback Guard Report/); + assert.match(report, /Sync: sync-clean-lms/); + assert.match(report, /Decision: approve-sync/); + assert.match(report, /Findings: 0/); +});