diff --git a/project-access-denial-appeal-guard/.gitignore b/project-access-denial-appeal-guard/.gitignore new file mode 100644 index 00000000..2bf074d6 --- /dev/null +++ b/project-access-denial-appeal-guard/.gitignore @@ -0,0 +1 @@ +reports/frames/ diff --git a/project-access-denial-appeal-guard/README.md b/project-access-denial-appeal-guard/README.md new file mode 100644 index 00000000..9a829a7b --- /dev/null +++ b/project-access-denial-appeal-guard/README.md @@ -0,0 +1,40 @@ +# Project Access Denial and Appeal Guard + +Self-contained synthetic module for SCIBASE issue #11, User & Project Management. + +The guard focuses on a narrow decision point: denied restricted-project access requests and appeal packets should be fair, timely, independently reviewed, requester-safe, and auditable before the final user-facing decision is sent. + +## What It Checks + +- Stable denied access request ids and requester ids. +- Specific denial reason codes and rationales. +- Requester-safe redaction of private denial and appeal notes. +- Appeal submission inside the configured appeal window. +- Independent appeal reviewer identity and eligible governance role. +- Required training, data-use agreement, and IRB evidence by project classification. +- Least-privilege object scope for restricted projects. +- User-facing audit receipts with reason code, appeal deadline, evidence checklist, decision timestamp, and appeal outcome. + +This is distinct from broad RBAC ledgers, privacy access review campaigns, member offboarding, break-glass access, collaborator conflict checks, data-residency transfers, and object-permission drift. It is specifically about the post-denial appeal workflow and the receipt that a denied researcher receives. + +## Run + +```sh +npm run check +npm test +npm run demo +npm run verify-video +``` + +## Outputs + +`npm run demo` writes: + +- `reports/clean-audit.json` +- `reports/risky-audit.json` +- `reports/risky-review.md` +- `reports/summary.svg` +- `reports/manifest.json` +- `reports/demo.mp4` + +The sample data is synthetic only. The module does not call identity providers, SAML/OAuth/ORCID services, GitHub, payment processors, private user/project systems, or external APIs. diff --git a/project-access-denial-appeal-guard/demo.js b/project-access-denial-appeal-guard/demo.js new file mode 100644 index 00000000..1a409861 --- /dev/null +++ b/project-access-denial-appeal-guard/demo.js @@ -0,0 +1,46 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { + evaluateAccessDenialAppeals, + renderMarkdownReport, + renderSvgSummary +} = require("./index"); +const { cleanPacket, riskyPacket } = require("./sample-data"); + +const reportsDir = path.join(__dirname, "reports"); +fs.mkdirSync(reportsDir, { recursive: true }); + +const clean = evaluateAccessDenialAppeals(cleanPacket); +const risky = evaluateAccessDenialAppeals(riskyPacket); + +fs.writeFileSync(path.join(reportsDir, "clean-audit.json"), `${JSON.stringify(clean, null, 2)}\n`); +fs.writeFileSync(path.join(reportsDir, "risky-audit.json"), `${JSON.stringify(risky, null, 2)}\n`); +fs.writeFileSync(path.join(reportsDir, "risky-review.md"), renderMarkdownReport(risky, riskyPacket)); +fs.writeFileSync(path.join(reportsDir, "summary.svg"), renderSvgSummary(risky)); +fs.writeFileSync( + path.join(reportsDir, "manifest.json"), + `${JSON.stringify( + { + generatedAt: new Date().toISOString(), + module: "project-access-denial-appeal-guard", + cleanStatus: clean.status, + riskyStatus: risky.status, + riskyFindings: risky.findings.length, + artifacts: [ + "clean-audit.json", + "risky-audit.json", + "risky-review.md", + "summary.svg", + "demo.mp4" + ] + }, + null, + 2 + )}\n` +); + +console.log(`Clean packet: ${clean.status} (${clean.findings.length} findings)`); +console.log(`Risky packet: ${risky.status} (${risky.findings.length} findings)`); +console.log(`Wrote reports to ${reportsDir}`); diff --git a/project-access-denial-appeal-guard/index.js b/project-access-denial-appeal-guard/index.js new file mode 100644 index 00000000..9251b40e --- /dev/null +++ b/project-access-denial-appeal-guard/index.js @@ -0,0 +1,583 @@ +"use strict"; + +const crypto = require("node:crypto"); + +const SEVERITY_ORDER = ["critical", "high", "warning", "info"]; + +const DEFAULT_POLICY = { + appealWindowDays: 14, + minRationaleCharacters: 24, + independentReviewerRoles: ["data-steward", "institution-admin", "ethics-reviewer"], + requiredEvidenceByClassification: { + restricted_human: ["trainingValid", "duaActive", "irbApproval"], + confidential: ["trainingValid", "duaActive"], + institutional_only: ["trainingValid"] + } +}; + +function evaluateAccessDenialAppeals(packet, options = {}) { + if (!isPlainObject(packet)) { + throw new TypeError("evaluateAccessDenialAppeals expects a packet object"); + } + + const now = options.now ?? new Date().toISOString(); + const policy = { + ...DEFAULT_POLICY, + ...(isPlainObject(packet.policy) ? packet.policy : {}), + requiredEvidenceByClassification: { + ...DEFAULT_POLICY.requiredEvidenceByClassification, + ...(isPlainObject(packet.policy?.requiredEvidenceByClassification) + ? packet.policy.requiredEvidenceByClassification + : {}) + } + }; + const accessRequests = asArray(packet.accessRequests); + const findings = []; + const appealDecisions = []; + + if (accessRequests.length === 0) { + findings.push( + finding( + "NO_ACCESS_REQUESTS", + "high", + "No denied project access requests were supplied.", + "The guard needs denial and appeal packets before a user-facing decision is finalized.", + "accessRequests", + "Attach denied access requests with denial reasons, appeal packets, evidence, and audit receipts.", + "workspace admin" + ) + ); + } + + accessRequests.forEach((request, index) => { + inspectAccessRequest(request, index, policy, findings, appealDecisions); + }); + + const sortedFindings = sortFindings(findings); + const status = determineStatus(sortedFindings); + const remediationActions = sortedFindings.map((item) => ({ + code: item.code, + requestId: item.requestId, + owner: item.owner, + action: item.remediation + })); + const summary = summarize(status, sortedFindings, appealDecisions); + const fingerprint = crypto + .createHash("sha256") + .update( + JSON.stringify({ + policy, + accessRequests: accessRequests.map((request) => ({ + id: request.id, + classification: request.project?.classification, + denialCode: request.denial?.reasonCode, + appealDecision: request.appeal?.decision, + findingCodes: sortedFindings.map((item) => item.code) + })) + }) + ) + .digest("hex") + .slice(0, 16); + + return { + generatedAt: now, + status, + summary, + findingCounts: countBySeverity(sortedFindings), + findings: sortedFindings, + appealDecisions, + remediationActions, + fingerprint + }; +} + +function renderMarkdownReport(result, packet) { + const lines = [ + "# Project Access Denial and Appeal Guard", + "", + `Packet: ${packet.id ?? "unknown"}`, + `Status: ${result.status}`, + `Fingerprint: ${result.fingerprint}`, + "", + "## Summary", + "", + result.summary, + "", + "## Appeal Decisions", + "" + ]; + + if (result.appealDecisions.length === 0) { + lines.push("- No appeal decisions were evaluated."); + } else { + result.appealDecisions.forEach((decision) => { + lines.push(`- ${decision.requestId}: ${decision.finalAction}; ${decision.findingCodes.length} finding(s)`); + lines.push(` - Requester: ${decision.requesterId}; project ${decision.projectId}`); + lines.push(` - Appeal state: ${decision.appealState}; receipt ${decision.receiptState}`); + }); + } + + lines.push("", "## Findings", ""); + if (result.findings.length === 0) { + lines.push("- No denial/appeal governance blockers found."); + } else { + result.findings.forEach((item) => { + lines.push(`- ${item.severity.toUpperCase()} ${item.code}: ${item.message}`); + lines.push(` - Evidence: ${item.evidence}`); + lines.push(` - Remediation: ${item.remediation}`); + }); + } + + return `${lines.join("\n")}\n`; +} + +function renderSvgSummary(result) { + const counts = result.findingCounts; + const blockers = (counts.critical ?? 0) + (counts.high ?? 0); + const warnings = counts.warning ?? 0; + const ready = result.status === "READY"; + const statusColor = ready ? "#16794c" : result.status === "REVIEW" ? "#a15c00" : "#a11b32"; + const safeWidth = ready ? 360 : Math.max(90, 360 - blockers * 45); + const blockerWidth = Math.min(330, blockers * 54); + const warningWidth = Math.min(220, warnings * 42); + + return [ + ``, + ``, + ``, + `Access denial appeal guard`, + `Status ${escapeXml(result.status)} - fingerprint ${escapeXml(result.fingerprint)}`, + ``, + ``, + ``, + `APPEAL REVIEW`, + `Critical/high blockers: ${blockers}`, + `Appeals checked: ${result.appealDecisions.length}`, + `Warning evidence gaps: ${warnings}`, + `` + ].join("\n"); +} + +function inspectAccessRequest(request, index, policy, findings, appealDecisions) { + const requestId = request.id ?? `access-request-${index}`; + const path = `accessRequests[${index}]`; + const project = isPlainObject(request.project) ? request.project : {}; + const requester = isPlainObject(request.requester) ? request.requester : {}; + const denial = isPlainObject(request.denial) ? request.denial : {}; + const appeal = isPlainObject(request.appeal) ? request.appeal : {}; + const objects = asArray(request.requestedObjects); + const auditReceipt = isPlainObject(request.auditReceipt) ? request.auditReceipt : {}; + + const beforeCount = findings.length; + + if (!request.id) { + findings.push( + finding( + "REQUEST_ID_MISSING", + "high", + `Access request at index ${index} has no stable id.`, + "Appeal packets must remain traceable across user notices and audit logs.", + `${path}.id`, + "Assign a stable access request id before finalizing the decision.", + "workspace admin", + requestId + ) + ); + } + + if (!requester.id) { + findings.push( + finding( + "REQUESTER_ID_MISSING", + "high", + `${requestId} has no requester id.`, + "The user-facing decision cannot be traced to a researcher profile or account.", + `${path}.requester.id`, + "Attach the requester account or researcher profile id.", + "identity admin", + requestId + ) + ); + } + + inspectDenial(requestId, path, denial, findings); + inspectAppeal(requestId, path, denial, appeal, project, policy, findings); + inspectEvidence(requestId, path, project, appeal, policy, findings); + inspectObjectScope(requestId, path, objects, project, findings); + inspectAuditReceipt(requestId, path, auditReceipt, denial, appeal, findings); + + const requestFindings = findings.slice(beforeCount); + appealDecisions.push({ + requestId, + requesterId: requester.id ?? null, + projectId: project.id ?? null, + classification: project.classification ?? "unknown", + appealState: appeal.submittedAt ? "submitted" : "missing", + finalAction: requestFindings.some((item) => item.severity === "critical" || item.severity === "high") + ? "hold-decision" + : appeal.decision ?? denial.outcome ?? "finalize-denial", + receiptState: auditReceipt.userFacingNotice ? "ready" : "missing", + findingCodes: requestFindings.map((item) => item.code) + }); +} + +function inspectDenial(requestId, path, denial, findings) { + if (!denial.reasonCode) { + findings.push( + finding( + "DENIAL_REASON_CODE_MISSING", + "high", + `${requestId} has no denial reason code.`, + "Researchers need a concrete denial basis before they can appeal or remediate access gaps.", + `${path}.denial.reasonCode`, + "Add a stable denial reason code such as TRAINING_EXPIRED, DUA_MISSING, IRB_SCOPE_MISMATCH, or OBJECT_SCOPE_TOO_BROAD.", + "workspace admin", + requestId + ) + ); + } + + if (!denial.rationale || String(denial.rationale).trim().length < DEFAULT_POLICY.minRationaleCharacters) { + findings.push( + finding( + "DENIAL_RATIONALE_TOO_THIN", + "high", + `${requestId} has an insufficient denial rationale.`, + "The user-facing decision needs enough context to be fair, appealable, and auditable.", + `${path}.denial.rationale`, + "Document the missing requirement, affected object scope, and remediation path.", + "workspace admin", + requestId + ) + ); + } + + if (denial.privateNotes && denial.redactedForRequester !== true) { + findings.push( + finding( + "PRIVATE_DENIAL_NOTE_EXPOSED", + "critical", + `${requestId} has private denial notes without requester-safe redaction.`, + "Denied-access notices must not leak hidden reviewer comments, protected collaborator details, or institutional security notes.", + `${path}.denial.redactedForRequester`, + "Redact private notes and expose only a requester-safe reason code plus remediation checklist.", + "privacy reviewer", + requestId + ) + ); + } +} + +function inspectAppeal(requestId, path, denial, appeal, project, policy, findings) { + if (!appeal.submittedAt) { + findings.push( + finding( + "APPEAL_PACKET_MISSING", + "warning", + `${requestId} has no appeal packet.`, + "The system can finalize a denial, but the audit packet should still record whether an appeal was offered.", + `${path}.appeal`, + "Record appeal deadline, appeal status, and evidence checklist even if the researcher does not appeal.", + "workspace admin", + requestId + ) + ); + return; + } + + if (denial.deniedAt && daysBetween(denial.deniedAt, appeal.submittedAt) > policy.appealWindowDays) { + findings.push( + finding( + "APPEAL_WINDOW_EXPIRED", + "high", + `${requestId} appeal was submitted after the ${policy.appealWindowDays}-day appeal window.`, + "Late appeals need explicit exception approval before a denial decision is changed.", + `${path}.appeal.submittedAt`, + "Route to institution-admin exception review or keep the denial final with a clear late-appeal receipt.", + "institution admin", + requestId + ) + ); + } + + if (!appeal.reviewerId) { + findings.push( + finding( + "APPEAL_REVIEWER_MISSING", + "high", + `${requestId} appeal has no reviewer id.`, + "Final appeal outcomes must identify the accountable reviewer.", + `${path}.appeal.reviewerId`, + "Assign an independent reviewer before finalizing the appeal.", + "workspace admin", + requestId + ) + ); + } else if (appeal.reviewerId === denial.deniedBy) { + findings.push( + finding( + "APPEAL_REVIEWER_NOT_INDEPENDENT", + "high", + `${requestId} appeal is reviewed by the same actor who denied access.`, + "Appeals require independence from the original denial decision.", + `${path}.appeal.reviewerId`, + "Assign an independent data steward, institution admin, or ethics reviewer.", + "workspace admin", + requestId + ) + ); + } + + if (!policy.independentReviewerRoles.includes(appeal.reviewerRole)) { + findings.push( + finding( + "APPEAL_REVIEWER_ROLE_NOT_ELIGIBLE", + "high", + `${requestId} appeal reviewer role ${appeal.reviewerRole ?? "unknown"} is not eligible.`, + `Eligible appeal reviewer roles: ${policy.independentReviewerRoles.join(", ")}.`, + `${path}.appeal.reviewerRole`, + "Assign a reviewer with an eligible governance role or document a policy exception.", + "workspace admin", + requestId + ) + ); + } + + if (appeal.privateNotes && appeal.redactedForRequester !== true) { + findings.push( + finding( + "PRIVATE_APPEAL_NOTE_EXPOSED", + "critical", + `${requestId} appeal includes private notes without requester-safe redaction.`, + "Appeal responses must not reveal private collaborator notes or hidden institutional risk comments.", + `${path}.appeal.redactedForRequester`, + "Redact private notes and publish only requester-safe appeal rationale.", + "privacy reviewer", + requestId + ) + ); + } + + if (appeal.decision === "approve" && project.classification === "restricted_human") { + const evidence = isPlainObject(appeal.evidence) ? appeal.evidence : {}; + const missing = (policy.requiredEvidenceByClassification.restricted_human ?? []).filter((key) => evidence[key] !== true); + if (missing.length > 0) { + findings.push( + finding( + "APPROVAL_WITH_MISSING_RESTRICTED_EVIDENCE", + "critical", + `${requestId} approves restricted human-data access while evidence is missing: ${missing.join(", ")}.`, + "Restricted access appeals cannot override training, DUA, or IRB prerequisites.", + `${path}.appeal.evidence`, + "Hold approval until all restricted-data prerequisites are present and current.", + "data steward", + requestId + ) + ); + } + } +} + +function inspectEvidence(requestId, path, project, appeal, policy, findings) { + const classification = project.classification ?? "institutional_only"; + const required = policy.requiredEvidenceByClassification[classification] ?? []; + const evidence = isPlainObject(appeal.evidence) ? appeal.evidence : {}; + + required.forEach((key) => { + if (evidence[key] !== true) { + findings.push( + finding( + "REQUIRED_APPEAL_EVIDENCE_MISSING", + "high", + `${requestId} is missing required evidence ${key} for ${classification} access.`, + "Appeal outcomes must prove the requester satisfied the project data-access prerequisites.", + `${path}.appeal.evidence.${key}`, + `Attach valid ${key} evidence or keep access denied with a remediation checklist.`, + "data steward", + requestId + ) + ); + } + }); +} + +function inspectObjectScope(requestId, path, objects, project, findings) { + if (objects.length === 0) { + findings.push( + finding( + "REQUESTED_OBJECT_SCOPE_MISSING", + "high", + `${requestId} has no object-level access scope.`, + "Project access decisions should state exactly which datasets, notebooks, documents, or comments were denied or appealed.", + `${path}.requestedObjects`, + "Attach object-level paths, ids, classifications, and requested permission levels.", + "workspace admin", + requestId + ) + ); + return; + } + + const broadObjects = objects.filter((object) => object.scope === "all-project" || object.path === "*"); + if (broadObjects.length > 0 && project.classification !== "public") { + findings.push( + finding( + "BROAD_RESTRICTED_SCOPE_REQUEST", + "high", + `${requestId} requests broad access to a non-public project.`, + "Restricted projects should appeal access at the narrowest object and permission scope that supports the research need.", + `${path}.requestedObjects`, + "Replace all-project scope with specific datasets, notebooks, manuscripts, or review threads and least-privilege permissions.", + "workspace admin", + requestId + ) + ); + } +} + +function inspectAuditReceipt(requestId, path, auditReceipt, denial, appeal, findings) { + if (auditReceipt.userFacingNotice !== true) { + findings.push( + finding( + "USER_FACING_RECEIPT_MISSING", + "high", + `${requestId} has no requester-facing denial/appeal receipt.`, + "Researchers need a stable notice showing the decision, appeal path, and remediation checklist.", + `${path}.auditReceipt.userFacingNotice`, + "Generate a requester-safe audit receipt with reason code, appeal deadline, outcome, and evidence checklist.", + "workspace admin", + requestId + ) + ); + } + + const requiredFields = ["reasonCode", "appealDeadline", "evidenceChecklist", "decisionTimestamp"]; + const missingFields = requiredFields.filter((field) => !asArray(auditReceipt.fields).includes(field)); + if (missingFields.length > 0) { + findings.push( + finding( + "AUDIT_RECEIPT_FIELDS_MISSING", + "warning", + `${requestId} audit receipt is missing fields: ${missingFields.join(", ")}.`, + "Complete receipts reduce support churn and make the appeal process auditable.", + `${path}.auditReceipt.fields`, + "Add the missing receipt fields before finalizing the denial or appeal decision.", + "workspace admin", + requestId + ) + ); + } + + if (denial.reasonCode && !asArray(auditReceipt.fields).includes("reasonCode")) { + findings.push( + finding( + "DENIAL_REASON_NOT_IN_RECEIPT", + "warning", + `${requestId} denial reason is not included in the user receipt.`, + "The requester needs the reason code to understand and remediate the denial.", + `${path}.auditReceipt.fields`, + "Include reasonCode in the user-facing receipt.", + "workspace admin", + requestId + ) + ); + } + + if (appeal.submittedAt && !asArray(auditReceipt.fields).includes("appealOutcome")) { + findings.push( + finding( + "APPEAL_OUTCOME_NOT_IN_RECEIPT", + "warning", + `${requestId} appeal outcome is not included in the user receipt.`, + "The requester-facing receipt should close the loop after an appeal is reviewed.", + `${path}.auditReceipt.fields`, + "Include appealOutcome and reviewerRole in the final appeal receipt.", + "workspace admin", + requestId + ) + ); + } +} + +function summarize(status, findings, appealDecisions) { + const blockers = findings.filter((item) => item.severity === "critical" || item.severity === "high").length; + if (status === "READY") { + return `All ${appealDecisions.length} denied-access appeal packet(s) are ready for requester-safe finalization.`; + } + if (status === "HOLD") { + return `Hold final access decisions: ${blockers} critical or high denial/appeal governance blocker(s) need remediation.`; + } + return `Review needed: ${findings.length} warning-level denial/appeal evidence gap(s) should be completed.`; +} + +function finding(code, severity, message, evidence, path, remediation, owner, requestId = null) { + return { + code, + severity, + message, + evidence, + path, + remediation, + owner, + requestId + }; +} + +function sortFindings(findings) { + return [...findings].sort((a, b) => { + const severityDelta = SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity); + if (severityDelta !== 0) { + return severityDelta; + } + return a.code.localeCompare(b.code); + }); +} + +function determineStatus(findings) { + if (findings.some((item) => item.severity === "critical" || item.severity === "high")) { + return "HOLD"; + } + if (findings.some((item) => item.severity === "warning")) { + return "REVIEW"; + } + return "READY"; +} + +function countBySeverity(findings) { + return findings.reduce((counts, item) => { + counts[item.severity] = (counts[item.severity] ?? 0) + 1; + return counts; + }, {}); +} + +function daysBetween(start, end) { + const startMs = Date.parse(start); + const endMs = Date.parse(end); + if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) { + return 0; + } + return (endMs - startMs) / 86400000; +} + +function asArray(value) { + return Array.isArray(value) ? value : []; +} + +function isPlainObject(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function escapeXml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +module.exports = { + DEFAULT_POLICY, + evaluateAccessDenialAppeals, + renderMarkdownReport, + renderSvgSummary +}; diff --git a/project-access-denial-appeal-guard/make-demo-video.js b/project-access-denial-appeal-guard/make-demo-video.js new file mode 100644 index 00000000..6d429401 --- /dev/null +++ b/project-access-denial-appeal-guard/make-demo-video.js @@ -0,0 +1,128 @@ +"use strict"; + +const { execFileSync } = require("node:child_process"); +const fs = require("node:fs"); +const path = require("node:path"); + +const WIDTH = 960; +const HEIGHT = 540; +const FONT = { + A: ["01110", "10001", "10001", "11111", "10001", "10001", "10001"], + C: ["01111", "10000", "10000", "10000", "10000", "10000", "01111"], + D: ["11110", "10001", "10001", "10001", "10001", "10001", "11110"], + E: ["11111", "10000", "10000", "11110", "10000", "10000", "11111"], + G: ["01111", "10000", "10000", "10111", "10001", "10001", "01111"], + H: ["10001", "10001", "10001", "11111", "10001", "10001", "10001"], + I: ["11111", "00100", "00100", "00100", "00100", "00100", "11111"], + L: ["10000", "10000", "10000", "10000", "10000", "10000", "11111"], + N: ["10001", "11001", "10101", "10011", "10001", "10001", "10001"], + O: ["01110", "10001", "10001", "10001", "10001", "10001", "01110"], + P: ["11110", "10001", "10001", "11110", "10000", "10000", "10000"], + R: ["11110", "10001", "10001", "11110", "10100", "10010", "10001"], + S: ["01111", "10000", "10000", "01110", "00001", "00001", "11110"], + T: ["11111", "00100", "00100", "00100", "00100", "00100", "00100"], + V: ["10001", "10001", "10001", "10001", "01010", "01010", "00100"], + W: ["10001", "10001", "10001", "10101", "10101", "10101", "01010"], + Y: ["10001", "01010", "00100", "00100", "00100", "00100", "00100"] +}; + +const reportsDir = path.join(__dirname, "reports"); +const framesDir = path.join(reportsDir, "frames"); +fs.mkdirSync(framesDir, { recursive: true }); + +for (const file of fs.readdirSync(framesDir)) { + fs.unlinkSync(path.join(framesDir, file)); +} + +const slides = [ + { label: "DENIAL REASON", color: [22, 121, 76], fill: 0.72 }, + { label: "APPEAL WINDOW", color: [161, 92, 0], fill: 0.82 }, + { label: "REVIEWER CHECK", color: [22, 121, 76], fill: 0.7 }, + { label: "PRIVATE HOLD", color: [161, 27, 50], fill: 0.92 } +]; + +let frameIndex = 0; +for (const slide of slides) { + for (let i = 0; i < 8; i += 1) { + const progress = (i + 1) / 8; + const buffer = createFrame(slide, progress); + fs.writeFileSync(path.join(framesDir, `frame-${String(frameIndex).padStart(3, "0")}.ppm`), buffer); + frameIndex += 1; + } +} + +const output = path.join(reportsDir, "demo.mp4"); +execFileSync( + "ffmpeg", + [ + "-y", + "-framerate", + "8", + "-i", + path.join(framesDir, "frame-%03d.ppm"), + "-pix_fmt", + "yuv420p", + "-movflags", + "+faststart", + output + ], + { stdio: "ignore" } +); + +const stats = fs.statSync(output); +console.log(`Wrote ${output} (${stats.size} bytes)`); + +function createFrame(slide, progress) { + const pixels = Buffer.alloc(WIDTH * HEIGHT * 3); + fillRect(pixels, 0, 0, WIDTH, HEIGHT, [17, 24, 39]); + fillRect(pixels, 48, 48, 864, 444, [248, 250, 252]); + fillRect(pixels, 80, 192, 800, 86, [226, 232, 240]); + fillRect(pixels, 80, 192, Math.round(800 * slide.fill * progress), 86, slide.color); + fillRect(pixels, 80, 324, 220, 42, [226, 232, 240]); + fillRect(pixels, 332, 324, 220, 42, [226, 232, 240]); + fillRect(pixels, 584, 324, 220, 42, [226, 232, 240]); + fillRect(pixels, 80, 324, 150, 42, [22, 121, 76]); + fillRect(pixels, 332, 324, 160, 42, [161, 92, 0]); + fillRect(pixels, 584, 324, 170, 42, [161, 27, 50]); + drawText(pixels, "ACCESS APPEAL", 82, 104, 5, [17, 24, 39]); + drawText(pixels, slide.label, 108, 216, 7, [255, 255, 255]); + drawText(pixels, "USER SAFE RECEIPT", 82, 416, 4, [51, 65, 85]); + return Buffer.concat([Buffer.from(`P6\n${WIDTH} ${HEIGHT}\n255\n`, "ascii"), pixels]); +} + +function fillRect(pixels, x, y, width, height, color) { + const x2 = Math.min(WIDTH, x + width); + const y2 = Math.min(HEIGHT, y + height); + for (let row = Math.max(0, y); row < y2; row += 1) { + for (let col = Math.max(0, x); col < x2; col += 1) { + const offset = (row * WIDTH + col) * 3; + pixels[offset] = color[0]; + pixels[offset + 1] = color[1]; + pixels[offset + 2] = color[2]; + } + } +} + +function drawText(pixels, text, x, y, scale, color) { + let cursor = x; + for (const rawChar of text) { + const char = rawChar.toUpperCase(); + if (char === " ") { + cursor += 4 * scale; + continue; + } + const glyph = FONT[char]; + if (!glyph) { + cursor += 6 * scale; + continue; + } + glyph.forEach((row, rowIndex) => { + for (let colIndex = 0; colIndex < row.length; colIndex += 1) { + if (row[colIndex] === "1") { + fillRect(pixels, cursor + colIndex * scale, y + rowIndex * scale, scale, scale, color); + } + } + }); + cursor += 6 * scale; + } +} diff --git a/project-access-denial-appeal-guard/package.json b/project-access-denial-appeal-guard/package.json new file mode 100644 index 00000000..4f9e3ab4 --- /dev/null +++ b/project-access-denial-appeal-guard/package.json @@ -0,0 +1,21 @@ +{ + "name": "project-access-denial-appeal-guard", + "version": "1.0.0", + "private": true, + "description": "Synthetic project access denial and appeal guard for scientific user/project management workflows.", + "main": "index.js", + "scripts": { + "check": "node --check index.js && node --check sample-data.js && node --check test.js && node --check demo.js && node --check make-demo-video.js", + "test": "node test.js", + "demo": "node demo.js && node make-demo-video.js", + "verify-video": "ffprobe -v error -show_entries stream=codec_name,width,height,duration -of default=nokey=1:noprint_wrappers=1 reports/demo.mp4" + }, + "keywords": [ + "access-control", + "appeals", + "user-management", + "audit", + "synthetic" + ], + "license": "MIT" +} diff --git a/project-access-denial-appeal-guard/reports/clean-audit.json b/project-access-denial-appeal-guard/reports/clean-audit.json new file mode 100644 index 00000000..45f7b9b0 --- /dev/null +++ b/project-access-denial-appeal-guard/reports/clean-audit.json @@ -0,0 +1,21 @@ +{ + "generatedAt": "2026-06-01T10:51:17.051Z", + "status": "READY", + "summary": "All 1 denied-access appeal packet(s) are ready for requester-safe finalization.", + "findingCounts": {}, + "findings": [], + "appealDecisions": [ + { + "requestId": "AR-1001", + "requesterId": "researcher-42", + "projectId": "proj-human-cohort-7", + "classification": "restricted_human", + "appealState": "submitted", + "finalAction": "uphold-denial", + "receiptState": "ready", + "findingCodes": [] + } + ], + "remediationActions": [], + "fingerprint": "31a620a33cd68018" +} diff --git a/project-access-denial-appeal-guard/reports/demo.mp4 b/project-access-denial-appeal-guard/reports/demo.mp4 new file mode 100644 index 00000000..39d8c227 Binary files /dev/null and b/project-access-denial-appeal-guard/reports/demo.mp4 differ diff --git a/project-access-denial-appeal-guard/reports/manifest.json b/project-access-denial-appeal-guard/reports/manifest.json new file mode 100644 index 00000000..10d55a6d --- /dev/null +++ b/project-access-denial-appeal-guard/reports/manifest.json @@ -0,0 +1,14 @@ +{ + "generatedAt": "2026-06-01T10:51:17.059Z", + "module": "project-access-denial-appeal-guard", + "cleanStatus": "READY", + "riskyStatus": "HOLD", + "riskyFindings": 14, + "artifacts": [ + "clean-audit.json", + "risky-audit.json", + "risky-review.md", + "summary.svg", + "demo.mp4" + ] +} diff --git a/project-access-denial-appeal-guard/reports/risky-audit.json b/project-access-denial-appeal-guard/reports/risky-audit.json new file mode 100644 index 00000000..47ddc6ca --- /dev/null +++ b/project-access-denial-appeal-guard/reports/risky-audit.json @@ -0,0 +1,266 @@ +{ + "generatedAt": "2026-06-01T10:51:17.052Z", + "status": "HOLD", + "summary": "Hold final access decisions: 12 critical or high denial/appeal governance blocker(s) need remediation.", + "findingCounts": { + "critical": 3, + "high": 9, + "warning": 2 + }, + "findings": [ + { + "code": "APPROVAL_WITH_MISSING_RESTRICTED_EVIDENCE", + "severity": "critical", + "message": "AR-2002 approves restricted human-data access while evidence is missing: duaActive, irbApproval.", + "evidence": "Restricted access appeals cannot override training, DUA, or IRB prerequisites.", + "path": "accessRequests[0].appeal.evidence", + "remediation": "Hold approval until all restricted-data prerequisites are present and current.", + "owner": "data steward", + "requestId": "AR-2002" + }, + { + "code": "PRIVATE_APPEAL_NOTE_EXPOSED", + "severity": "critical", + "message": "AR-2002 appeal includes private notes without requester-safe redaction.", + "evidence": "Appeal responses must not reveal private collaborator notes or hidden institutional risk comments.", + "path": "accessRequests[0].appeal.redactedForRequester", + "remediation": "Redact private notes and publish only requester-safe appeal rationale.", + "owner": "privacy reviewer", + "requestId": "AR-2002" + }, + { + "code": "PRIVATE_DENIAL_NOTE_EXPOSED", + "severity": "critical", + "message": "AR-2002 has private denial notes without requester-safe redaction.", + "evidence": "Denied-access notices must not leak hidden reviewer comments, protected collaborator details, or institutional security notes.", + "path": "accessRequests[0].denial.redactedForRequester", + "remediation": "Redact private notes and expose only a requester-safe reason code plus remediation checklist.", + "owner": "privacy reviewer", + "requestId": "AR-2002" + }, + { + "code": "APPEAL_REVIEWER_NOT_INDEPENDENT", + "severity": "high", + "message": "AR-2002 appeal is reviewed by the same actor who denied access.", + "evidence": "Appeals require independence from the original denial decision.", + "path": "accessRequests[0].appeal.reviewerId", + "remediation": "Assign an independent data steward, institution admin, or ethics reviewer.", + "owner": "workspace admin", + "requestId": "AR-2002" + }, + { + "code": "APPEAL_REVIEWER_ROLE_NOT_ELIGIBLE", + "severity": "high", + "message": "AR-2002 appeal reviewer role viewer is not eligible.", + "evidence": "Eligible appeal reviewer roles: data-steward, institution-admin, ethics-reviewer.", + "path": "accessRequests[0].appeal.reviewerRole", + "remediation": "Assign a reviewer with an eligible governance role or document a policy exception.", + "owner": "workspace admin", + "requestId": "AR-2002" + }, + { + "code": "APPEAL_WINDOW_EXPIRED", + "severity": "high", + "message": "AR-2002 appeal was submitted after the 14-day appeal window.", + "evidence": "Late appeals need explicit exception approval before a denial decision is changed.", + "path": "accessRequests[0].appeal.submittedAt", + "remediation": "Route to institution-admin exception review or keep the denial final with a clear late-appeal receipt.", + "owner": "institution admin", + "requestId": "AR-2002" + }, + { + "code": "BROAD_RESTRICTED_SCOPE_REQUEST", + "severity": "high", + "message": "AR-2002 requests broad access to a non-public project.", + "evidence": "Restricted projects should appeal access at the narrowest object and permission scope that supports the research need.", + "path": "accessRequests[0].requestedObjects", + "remediation": "Replace all-project scope with specific datasets, notebooks, manuscripts, or review threads and least-privilege permissions.", + "owner": "workspace admin", + "requestId": "AR-2002" + }, + { + "code": "DENIAL_RATIONALE_TOO_THIN", + "severity": "high", + "message": "AR-2002 has an insufficient denial rationale.", + "evidence": "The user-facing decision needs enough context to be fair, appealable, and auditable.", + "path": "accessRequests[0].denial.rationale", + "remediation": "Document the missing requirement, affected object scope, and remediation path.", + "owner": "workspace admin", + "requestId": "AR-2002" + }, + { + "code": "DENIAL_REASON_CODE_MISSING", + "severity": "high", + "message": "AR-2002 has no denial reason code.", + "evidence": "Researchers need a concrete denial basis before they can appeal or remediate access gaps.", + "path": "accessRequests[0].denial.reasonCode", + "remediation": "Add a stable denial reason code such as TRAINING_EXPIRED, DUA_MISSING, IRB_SCOPE_MISMATCH, or OBJECT_SCOPE_TOO_BROAD.", + "owner": "workspace admin", + "requestId": "AR-2002" + }, + { + "code": "REQUIRED_APPEAL_EVIDENCE_MISSING", + "severity": "high", + "message": "AR-2002 is missing required evidence duaActive for restricted_human access.", + "evidence": "Appeal outcomes must prove the requester satisfied the project data-access prerequisites.", + "path": "accessRequests[0].appeal.evidence.duaActive", + "remediation": "Attach valid duaActive evidence or keep access denied with a remediation checklist.", + "owner": "data steward", + "requestId": "AR-2002" + }, + { + "code": "REQUIRED_APPEAL_EVIDENCE_MISSING", + "severity": "high", + "message": "AR-2002 is missing required evidence irbApproval for restricted_human access.", + "evidence": "Appeal outcomes must prove the requester satisfied the project data-access prerequisites.", + "path": "accessRequests[0].appeal.evidence.irbApproval", + "remediation": "Attach valid irbApproval evidence or keep access denied with a remediation checklist.", + "owner": "data steward", + "requestId": "AR-2002" + }, + { + "code": "USER_FACING_RECEIPT_MISSING", + "severity": "high", + "message": "AR-2002 has no requester-facing denial/appeal receipt.", + "evidence": "Researchers need a stable notice showing the decision, appeal path, and remediation checklist.", + "path": "accessRequests[0].auditReceipt.userFacingNotice", + "remediation": "Generate a requester-safe audit receipt with reason code, appeal deadline, outcome, and evidence checklist.", + "owner": "workspace admin", + "requestId": "AR-2002" + }, + { + "code": "APPEAL_OUTCOME_NOT_IN_RECEIPT", + "severity": "warning", + "message": "AR-2002 appeal outcome is not included in the user receipt.", + "evidence": "The requester-facing receipt should close the loop after an appeal is reviewed.", + "path": "accessRequests[0].auditReceipt.fields", + "remediation": "Include appealOutcome and reviewerRole in the final appeal receipt.", + "owner": "workspace admin", + "requestId": "AR-2002" + }, + { + "code": "AUDIT_RECEIPT_FIELDS_MISSING", + "severity": "warning", + "message": "AR-2002 audit receipt is missing fields: reasonCode, appealDeadline, evidenceChecklist.", + "evidence": "Complete receipts reduce support churn and make the appeal process auditable.", + "path": "accessRequests[0].auditReceipt.fields", + "remediation": "Add the missing receipt fields before finalizing the denial or appeal decision.", + "owner": "workspace admin", + "requestId": "AR-2002" + } + ], + "appealDecisions": [ + { + "requestId": "AR-2002", + "requesterId": "researcher-99", + "projectId": "proj-human-cohort-9", + "classification": "restricted_human", + "appealState": "submitted", + "finalAction": "hold-decision", + "receiptState": "missing", + "findingCodes": [ + "DENIAL_REASON_CODE_MISSING", + "DENIAL_RATIONALE_TOO_THIN", + "PRIVATE_DENIAL_NOTE_EXPOSED", + "APPEAL_WINDOW_EXPIRED", + "APPEAL_REVIEWER_NOT_INDEPENDENT", + "APPEAL_REVIEWER_ROLE_NOT_ELIGIBLE", + "PRIVATE_APPEAL_NOTE_EXPOSED", + "APPROVAL_WITH_MISSING_RESTRICTED_EVIDENCE", + "REQUIRED_APPEAL_EVIDENCE_MISSING", + "REQUIRED_APPEAL_EVIDENCE_MISSING", + "BROAD_RESTRICTED_SCOPE_REQUEST", + "USER_FACING_RECEIPT_MISSING", + "AUDIT_RECEIPT_FIELDS_MISSING", + "APPEAL_OUTCOME_NOT_IN_RECEIPT" + ] + } + ], + "remediationActions": [ + { + "code": "APPROVAL_WITH_MISSING_RESTRICTED_EVIDENCE", + "requestId": "AR-2002", + "owner": "data steward", + "action": "Hold approval until all restricted-data prerequisites are present and current." + }, + { + "code": "PRIVATE_APPEAL_NOTE_EXPOSED", + "requestId": "AR-2002", + "owner": "privacy reviewer", + "action": "Redact private notes and publish only requester-safe appeal rationale." + }, + { + "code": "PRIVATE_DENIAL_NOTE_EXPOSED", + "requestId": "AR-2002", + "owner": "privacy reviewer", + "action": "Redact private notes and expose only a requester-safe reason code plus remediation checklist." + }, + { + "code": "APPEAL_REVIEWER_NOT_INDEPENDENT", + "requestId": "AR-2002", + "owner": "workspace admin", + "action": "Assign an independent data steward, institution admin, or ethics reviewer." + }, + { + "code": "APPEAL_REVIEWER_ROLE_NOT_ELIGIBLE", + "requestId": "AR-2002", + "owner": "workspace admin", + "action": "Assign a reviewer with an eligible governance role or document a policy exception." + }, + { + "code": "APPEAL_WINDOW_EXPIRED", + "requestId": "AR-2002", + "owner": "institution admin", + "action": "Route to institution-admin exception review or keep the denial final with a clear late-appeal receipt." + }, + { + "code": "BROAD_RESTRICTED_SCOPE_REQUEST", + "requestId": "AR-2002", + "owner": "workspace admin", + "action": "Replace all-project scope with specific datasets, notebooks, manuscripts, or review threads and least-privilege permissions." + }, + { + "code": "DENIAL_RATIONALE_TOO_THIN", + "requestId": "AR-2002", + "owner": "workspace admin", + "action": "Document the missing requirement, affected object scope, and remediation path." + }, + { + "code": "DENIAL_REASON_CODE_MISSING", + "requestId": "AR-2002", + "owner": "workspace admin", + "action": "Add a stable denial reason code such as TRAINING_EXPIRED, DUA_MISSING, IRB_SCOPE_MISMATCH, or OBJECT_SCOPE_TOO_BROAD." + }, + { + "code": "REQUIRED_APPEAL_EVIDENCE_MISSING", + "requestId": "AR-2002", + "owner": "data steward", + "action": "Attach valid duaActive evidence or keep access denied with a remediation checklist." + }, + { + "code": "REQUIRED_APPEAL_EVIDENCE_MISSING", + "requestId": "AR-2002", + "owner": "data steward", + "action": "Attach valid irbApproval evidence or keep access denied with a remediation checklist." + }, + { + "code": "USER_FACING_RECEIPT_MISSING", + "requestId": "AR-2002", + "owner": "workspace admin", + "action": "Generate a requester-safe audit receipt with reason code, appeal deadline, outcome, and evidence checklist." + }, + { + "code": "APPEAL_OUTCOME_NOT_IN_RECEIPT", + "requestId": "AR-2002", + "owner": "workspace admin", + "action": "Include appealOutcome and reviewerRole in the final appeal receipt." + }, + { + "code": "AUDIT_RECEIPT_FIELDS_MISSING", + "requestId": "AR-2002", + "owner": "workspace admin", + "action": "Add the missing receipt fields before finalizing the denial or appeal decision." + } + ], + "fingerprint": "fde2d21c50c6dd89" +} diff --git a/project-access-denial-appeal-guard/reports/risky-review.md b/project-access-denial-appeal-guard/reports/risky-review.md new file mode 100644 index 00000000..1d3dc39c --- /dev/null +++ b/project-access-denial-appeal-guard/reports/risky-review.md @@ -0,0 +1,60 @@ +# Project Access Denial and Appeal Guard + +Packet: risky-access-appeals +Status: HOLD +Fingerprint: fde2d21c50c6dd89 + +## Summary + +Hold final access decisions: 12 critical or high denial/appeal governance blocker(s) need remediation. + +## Appeal Decisions + +- AR-2002: hold-decision; 14 finding(s) + - Requester: researcher-99; project proj-human-cohort-9 + - Appeal state: submitted; receipt missing + +## Findings + +- CRITICAL APPROVAL_WITH_MISSING_RESTRICTED_EVIDENCE: AR-2002 approves restricted human-data access while evidence is missing: duaActive, irbApproval. + - Evidence: Restricted access appeals cannot override training, DUA, or IRB prerequisites. + - Remediation: Hold approval until all restricted-data prerequisites are present and current. +- CRITICAL PRIVATE_APPEAL_NOTE_EXPOSED: AR-2002 appeal includes private notes without requester-safe redaction. + - Evidence: Appeal responses must not reveal private collaborator notes or hidden institutional risk comments. + - Remediation: Redact private notes and publish only requester-safe appeal rationale. +- CRITICAL PRIVATE_DENIAL_NOTE_EXPOSED: AR-2002 has private denial notes without requester-safe redaction. + - Evidence: Denied-access notices must not leak hidden reviewer comments, protected collaborator details, or institutional security notes. + - Remediation: Redact private notes and expose only a requester-safe reason code plus remediation checklist. +- HIGH APPEAL_REVIEWER_NOT_INDEPENDENT: AR-2002 appeal is reviewed by the same actor who denied access. + - Evidence: Appeals require independence from the original denial decision. + - Remediation: Assign an independent data steward, institution admin, or ethics reviewer. +- HIGH APPEAL_REVIEWER_ROLE_NOT_ELIGIBLE: AR-2002 appeal reviewer role viewer is not eligible. + - Evidence: Eligible appeal reviewer roles: data-steward, institution-admin, ethics-reviewer. + - Remediation: Assign a reviewer with an eligible governance role or document a policy exception. +- HIGH APPEAL_WINDOW_EXPIRED: AR-2002 appeal was submitted after the 14-day appeal window. + - Evidence: Late appeals need explicit exception approval before a denial decision is changed. + - Remediation: Route to institution-admin exception review or keep the denial final with a clear late-appeal receipt. +- HIGH BROAD_RESTRICTED_SCOPE_REQUEST: AR-2002 requests broad access to a non-public project. + - Evidence: Restricted projects should appeal access at the narrowest object and permission scope that supports the research need. + - Remediation: Replace all-project scope with specific datasets, notebooks, manuscripts, or review threads and least-privilege permissions. +- HIGH DENIAL_RATIONALE_TOO_THIN: AR-2002 has an insufficient denial rationale. + - Evidence: The user-facing decision needs enough context to be fair, appealable, and auditable. + - Remediation: Document the missing requirement, affected object scope, and remediation path. +- HIGH DENIAL_REASON_CODE_MISSING: AR-2002 has no denial reason code. + - Evidence: Researchers need a concrete denial basis before they can appeal or remediate access gaps. + - Remediation: Add a stable denial reason code such as TRAINING_EXPIRED, DUA_MISSING, IRB_SCOPE_MISMATCH, or OBJECT_SCOPE_TOO_BROAD. +- HIGH REQUIRED_APPEAL_EVIDENCE_MISSING: AR-2002 is missing required evidence duaActive for restricted_human access. + - Evidence: Appeal outcomes must prove the requester satisfied the project data-access prerequisites. + - Remediation: Attach valid duaActive evidence or keep access denied with a remediation checklist. +- HIGH REQUIRED_APPEAL_EVIDENCE_MISSING: AR-2002 is missing required evidence irbApproval for restricted_human access. + - Evidence: Appeal outcomes must prove the requester satisfied the project data-access prerequisites. + - Remediation: Attach valid irbApproval evidence or keep access denied with a remediation checklist. +- HIGH USER_FACING_RECEIPT_MISSING: AR-2002 has no requester-facing denial/appeal receipt. + - Evidence: Researchers need a stable notice showing the decision, appeal path, and remediation checklist. + - Remediation: Generate a requester-safe audit receipt with reason code, appeal deadline, outcome, and evidence checklist. +- WARNING APPEAL_OUTCOME_NOT_IN_RECEIPT: AR-2002 appeal outcome is not included in the user receipt. + - Evidence: The requester-facing receipt should close the loop after an appeal is reviewed. + - Remediation: Include appealOutcome and reviewerRole in the final appeal receipt. +- WARNING AUDIT_RECEIPT_FIELDS_MISSING: AR-2002 audit receipt is missing fields: reasonCode, appealDeadline, evidenceChecklist. + - Evidence: Complete receipts reduce support churn and make the appeal process auditable. + - Remediation: Add the missing receipt fields before finalizing the denial or appeal decision. diff --git a/project-access-denial-appeal-guard/reports/summary.svg b/project-access-denial-appeal-guard/reports/summary.svg new file mode 100644 index 00000000..82e4286e --- /dev/null +++ b/project-access-denial-appeal-guard/reports/summary.svg @@ -0,0 +1,13 @@ + + + +Access denial appeal guard +Status HOLD - fingerprint fde2d21c50c6dd89 + + + +APPEAL REVIEW +Critical/high blockers: 12 +Appeals checked: 1 +Warning evidence gaps: 2 + \ No newline at end of file diff --git a/project-access-denial-appeal-guard/sample-data.js b/project-access-denial-appeal-guard/sample-data.js new file mode 100644 index 00000000..53793de8 --- /dev/null +++ b/project-access-denial-appeal-guard/sample-data.js @@ -0,0 +1,121 @@ +"use strict"; + +const cleanPacket = { + id: "clean-access-appeals", + accessRequests: [ + { + id: "AR-1001", + requester: { + id: "researcher-42", + institution: "Northeast Medical Lab", + orcidVerified: true, + mfaFresh: true + }, + project: { + id: "proj-human-cohort-7", + classification: "restricted_human", + visibility: "institutional-only" + }, + requestedObjects: [ + { + id: "dataset-redacted-v2", + path: "data/redacted-cohort-v2.csv", + permission: "download", + classification: "restricted_human" + }, + { + id: "notebook-analysis", + path: "notebooks/reproduce.ipynb", + permission: "read", + classification: "institutional_only" + } + ], + denial: { + deniedAt: "2026-05-20T12:00:00.000Z", + deniedBy: "steward-a", + reasonCode: "IRB_SCOPE_REVIEW_REQUIRED", + rationale: "The requested cohort export needs an IRB scope check before download access can be granted.", + outcome: "deny-with-remediation", + privateNotes: "Internal reviewer note: contact PI before export.", + redactedForRequester: true + }, + appeal: { + submittedAt: "2026-05-24T09:00:00.000Z", + reviewerId: "ethics-reviewer-b", + reviewerRole: "ethics-reviewer", + decision: "uphold-denial", + rationale: "Appeal reviewed. Access remains denied until the amended IRB letter is attached.", + redactedForRequester: true, + evidence: { + trainingValid: true, + duaActive: true, + irbApproval: true + } + }, + auditReceipt: { + userFacingNotice: true, + fields: ["reasonCode", "appealDeadline", "evidenceChecklist", "decisionTimestamp", "appealOutcome", "reviewerRole"] + } + } + ] +}; + +const riskyPacket = { + id: "risky-access-appeals", + accessRequests: [ + { + id: "AR-2002", + requester: { + id: "researcher-99", + institution: "External Partner Lab", + orcidVerified: false, + mfaFresh: false + }, + project: { + id: "proj-human-cohort-9", + classification: "restricted_human", + visibility: "invitation-only" + }, + requestedObjects: [ + { + id: "all-project", + path: "*", + scope: "all-project", + permission: "download", + classification: "restricted_human" + } + ], + denial: { + deniedAt: "2026-05-01T12:00:00.000Z", + deniedBy: "steward-a", + reasonCode: "", + rationale: "No.", + privateNotes: "Private: reviewer suspects a competing project conflict.", + redactedForRequester: false + }, + appeal: { + submittedAt: "2026-05-25T09:00:00.000Z", + reviewerId: "steward-a", + reviewerRole: "viewer", + decision: "approve", + rationale: "Approved after appeal.", + privateNotes: "Internal note: sponsor did not respond.", + redactedForRequester: false, + evidence: { + trainingValid: true, + duaActive: false, + irbApproval: false + } + }, + auditReceipt: { + userFacingNotice: false, + fields: ["decisionTimestamp"] + } + } + ] +}; + +module.exports = { + cleanPacket, + riskyPacket +}; diff --git a/project-access-denial-appeal-guard/test.js b/project-access-denial-appeal-guard/test.js new file mode 100644 index 00000000..3de48def --- /dev/null +++ b/project-access-denial-appeal-guard/test.js @@ -0,0 +1,36 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const { + evaluateAccessDenialAppeals, + renderMarkdownReport, + renderSvgSummary +} = require("./index"); +const { cleanPacket, riskyPacket } = require("./sample-data"); + +const clean = evaluateAccessDenialAppeals(cleanPacket, { now: "2026-06-01T11:00:00.000Z" }); +assert.equal(clean.status, "READY"); +assert.equal(clean.findings.length, 0); +assert.equal(clean.appealDecisions.length, 1); +assert.equal(clean.appealDecisions[0].finalAction, "uphold-denial"); + +const risky = evaluateAccessDenialAppeals(riskyPacket, { now: "2026-06-01T11:00:00.000Z" }); +assert.equal(risky.status, "HOLD"); +assert.ok(risky.findings.some((finding) => finding.code === "PRIVATE_DENIAL_NOTE_EXPOSED")); +assert.ok(risky.findings.some((finding) => finding.code === "PRIVATE_APPEAL_NOTE_EXPOSED")); +assert.ok(risky.findings.some((finding) => finding.code === "APPEAL_WINDOW_EXPIRED")); +assert.ok(risky.findings.some((finding) => finding.code === "APPEAL_REVIEWER_NOT_INDEPENDENT")); +assert.ok(risky.findings.some((finding) => finding.code === "APPROVAL_WITH_MISSING_RESTRICTED_EVIDENCE")); +assert.equal(risky.appealDecisions[0].finalAction, "hold-decision"); + +const markdown = renderMarkdownReport(risky, riskyPacket); +assert.match(markdown, /Project Access Denial and Appeal Guard/); +assert.match(markdown, /PRIVATE_DENIAL_NOTE_EXPOSED/); + +const svg = renderSvgSummary(risky); +assert.match(svg, / evaluateAccessDenialAppeals(null), /expects a packet object/); + +console.log("project-access-denial-appeal-guard tests passed");