Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions enterprise-lms-roster-passback-guard/README.md
Original file line number Diff line number Diff line change
@@ -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.
59 changes: 59 additions & 0 deletions enterprise-lms-roster-passback-guard/demo.js
Original file line number Diff line number Diff line change
@@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540">
<rect width="960" height="540" fill="#111827"/>
<text x="48" y="72" fill="#f9fafb" font-family="Arial, sans-serif" font-size="34" font-weight="700">Enterprise LMS Roster Passback Guard</text>
<text x="48" y="112" fill="#bfdbfe" font-family="Arial, sans-serif" font-size="18">Synthetic Canvas/Moodle roster and grade sync safety packet</text>
<g transform="translate(48 158)">
<rect width="190" height="154" rx="10" fill="#065f46"/>
<text x="22" y="48" fill="#d1fae5" font-family="Arial, sans-serif" font-size="18" font-weight="700">Approve</text>
<text x="22" y="108" fill="#ecfdf5" font-family="Arial, sans-serif" font-size="54" font-weight="700">${approved}</text>
</g>
<g transform="translate(270 158)">
<rect width="190" height="154" rx="10" fill="#854d0e"/>
<text x="22" y="48" fill="#fef3c7" font-family="Arial, sans-serif" font-size="18" font-weight="700">Admin Review</text>
<text x="22" y="108" fill="#fffbeb" font-family="Arial, sans-serif" font-size="54" font-weight="700">${review}</text>
</g>
<g transform="translate(492 158)">
<rect width="190" height="154" rx="10" fill="#7c2d12"/>
<text x="22" y="48" fill="#fed7aa" font-family="Arial, sans-serif" font-size="18" font-weight="700">Passback Hold</text>
<text x="22" y="108" fill="#fff7ed" font-family="Arial, sans-serif" font-size="54" font-weight="700">${passback}</text>
</g>
<g transform="translate(714 158)">
<rect width="190" height="154" rx="10" fill="#991b1b"/>
<text x="22" y="48" fill="#fee2e2" font-family="Arial, sans-serif" font-size="18" font-weight="700">Block</text>
<text x="22" y="108" fill="#fef2f2" font-family="Arial, sans-serif" font-size="54" font-weight="700">${blocked}</text>
</g>
<text x="48" y="382" fill="#e5e7eb" font-family="Arial, sans-serif" font-size="22">Checks: approved sections, FERPA consent, ORCID linkage, release windows, webhook receipts</text>
<text x="48" y="424" fill="#d1d5db" font-family="Arial, sans-serif" font-size="18">Reviewer findings: ${findings}. Outputs: JSON packet, Markdown report, SVG summary, MP4 artifact.</text>
<text x="48" y="478" fill="#9ca3af" font-family="Arial, sans-serif" font-size="16">Synthetic data only. No live LMS calls, student records, credentials, or external providers.</text>
</svg>
`;

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}`);
220 changes: 220 additions & 0 deletions enterprise-lms-roster-passback-guard/index.js
Original file line number Diff line number Diff line change
@@ -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,
};
Binary file not shown.
Loading