From 6d5f3b62da7ee3a16398a587386d256173cecbb0 Mon Sep 17 00:00:00 2001 From: AlonePenguin <187998801+AlonePenguin@users.noreply.github.com> Date: Mon, 1 Jun 2026 06:51:36 -0400 Subject: [PATCH] Add project access denial appeal guard --- project-access-denial-appeal-guard/.gitignore | 1 + project-access-denial-appeal-guard/README.md | 40 ++ project-access-denial-appeal-guard/demo.js | 46 ++ project-access-denial-appeal-guard/index.js | 583 ++++++++++++++++++ .../make-demo-video.js | 128 ++++ .../package.json | 21 + .../reports/clean-audit.json | 21 + .../reports/demo.mp4 | Bin 0 -> 25478 bytes .../reports/manifest.json | 14 + .../reports/risky-audit.json | 266 ++++++++ .../reports/risky-review.md | 60 ++ .../reports/summary.svg | 13 + .../sample-data.js | 121 ++++ project-access-denial-appeal-guard/test.js | 36 ++ 14 files changed, 1350 insertions(+) create mode 100644 project-access-denial-appeal-guard/.gitignore create mode 100644 project-access-denial-appeal-guard/README.md create mode 100644 project-access-denial-appeal-guard/demo.js create mode 100644 project-access-denial-appeal-guard/index.js create mode 100644 project-access-denial-appeal-guard/make-demo-video.js create mode 100644 project-access-denial-appeal-guard/package.json create mode 100644 project-access-denial-appeal-guard/reports/clean-audit.json create mode 100644 project-access-denial-appeal-guard/reports/demo.mp4 create mode 100644 project-access-denial-appeal-guard/reports/manifest.json create mode 100644 project-access-denial-appeal-guard/reports/risky-audit.json create mode 100644 project-access-denial-appeal-guard/reports/risky-review.md create mode 100644 project-access-denial-appeal-guard/reports/summary.svg create mode 100644 project-access-denial-appeal-guard/sample-data.js create mode 100644 project-access-denial-appeal-guard/test.js 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 0000000000000000000000000000000000000000..39d8c227e3b264284b4306a8101aca324be10b5e GIT binary patch literal 25478 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≦tzs(D=QNd8o)%uRBjn@F8{?=qO9*8Ni6%5T!ol*mLfdGzg;!Zw`v#F^lH8W{oFJV_p#`@<$PDuBpf#I+tZy2*#oWYz>Icd60%0zDz6f?w?zqG&^(Q@Vp zI@vdrIJ@(ez`E;TiUx8q2!5u;nx) zKHd(88h_tl?TOdw+#c;94ASp)u#Vh;YeFane+deCmzyudjF zs>uz+h?Ti=P;zTCd0lw^cwi*=|0YlX$rIipbq0yssaeU&2Dv~@lLItovt0cQw zCNPso3$$Z$)+alfYb7Ud2vqi##L>zTT% z4I#%@s} zJYm6oQ*nnScbBuO4wo*2i8d3}p@d76)0ur~#Wy!N0Z@1>(pgg9Gt3-vtl&eoSgwks zJEdmPB=loNoJS{DdwrA2JBPkvuCix>i9IZzXRY|;Hvb|gp z-=TEUXeQIfD9jDH`jkW1Mj**YIS0Omi2B?WGuh#VT>n)qOLQ!;6MWg{E`5slB$TO8 zn&Wgf#+VcPc+_T}KW&&>ZTVu)8v33*t-~6lBe74emDNUXL^h@J4`(PD7??!kiqhtd zts{=lzw78|V{Aqy!jK>_{Zbo-0~Txd2yr}&!wB^*XU(?LJO9+(Ej|Y-3<=UG`mjux z7#5%aGpQ+~*bvFKMkT&{*zZAZ?*4*hvJ)JAC2G)rF(7I@2fHc^fPnC`CJDYQkiX|F z`4p&MXH{x^#PcDo_4gaGy0CJ#*2x*E2~KkV1-)>NObeSpitDEjw1$;|fS1~5&JWac zUCX^>qm>1XU%l7N*T{x`9;~A!X4V3Cc$c37JZB&S4qPG`4PU}v5J6CofjT}HA6{Tb zI8Am^izR;PK2gQ})bti7h~Decv|DM^Cq2!Y@jS<@dxA7-mi9xpfPUEFO9YBM2!tTP zBPbo~xm~x-F@!RZrJZudjwLIO;PDF{K!%#Ske_l`03bS3urvCP{k_W%Fs}x&qZywg z(_KhQXVlv?q`+t8a`iuOSw`W2Pm+ymx^JPEXYAGkUjA_y@L6ETWha(jDuux)06v!p zFOjkUk>7I>Xh$5t_4&sGCcOYoX$L5aNY(_>C9R#ZUGr(Ug_ZBY95bQnY8p^FVEb5h z-iMlD%>4Lr$@-|h$(a@UB7QB9YV zg_<&EVqaQdITWt12W^oH7VG8Mt!PbTr@N#2-nAkCALE1V5X&9|drkZQ@|s|bYVQ!V z;_zlcHRq40RO3BeGMxCw4t{MOKF&AP`?#`PL;w^!c-29`_0Z14k}=!xbUDN%dPXEdf>)8r3n=@6y({qMEMQX5>(n9o2;P3Gw? z^3Qul8zE5pb^V1WI<$T-4PyZqh>RLMAv;)W%1&<~3}|7=7OO|cumKkYD-d6H&7<|| z5yD9#ef!PQt#5R^1bA?2HpfT}Xg`$|^utD$Vo8#jCfu8QY z)$qHH4-?=%jcSmPdXk}s9bi;N@Fe>VYkbySN1WnhcvcxbND5K4P!N1AS!gQScDRQ@ z?j-)&!DAM4e(05yT<;%fm^xEivY+$GWO;EJ@oTWg+T41EqFN0K+&7RSC$>Y;jp`d`|Sb^;t~N?)$8YK3;C_PQ~DSH^@P4mo9p50KD4 zLK)2}cU?>OPt8MRd7%~9Hz;wKaM&9Rh6|bBDMavl3$dGPaaOr2%^_LO1aFMAvtsuO zB0y2~$n7tD^7(`0)0o?R&^M@4hCt?E8b_-Pt&oeXmBE;-c8+nt{I?xW^;rZbHQC?2xkCDOh8M^CcB z74kja@=h&!29lJy@MK@!fZSs#oHhY)U$%BwSSNQ4tdW}+a`xRVE^-SSa-){9sj3o- zEjS8qzv}tZjj?-wHqI3IC86EG?fMz6HS+_o_lUf9=J)zodQ1?93v0br{=DPPhC9`v zi2Nj}4q1Ha3dc9Uz9RIJKEu2rq})B?r4-d02V>C` z?0jB*aE2OlVsZ_UI}T76brls&hlgPfbI)LyGFkc9(wWYM!1{qZ*V21dOH6cI8mc>O zToUIw84>ZD+b;r_AJmQasl{9}YfL`o6?61E9N`+(esUKHms~dVxit!S9f)N({f4j= zL`~E*OF6e12g#b=o3T!b$n|O6;Wp)lOnga4Yll+F33Fcy2ENj5{#XtBz%M+oMecID zgX-4YC4p<-Z)`7 z@miX{Ys0v{;d0o~!|3;u{POMl(;@e@&PKi+u3>|hF}}Cs?ItDPZbo?wALv+7HMV;W zxb#t?`HOwjv-?_zAi@* zXW2ND1Zb)|`0)=ckEep*5N;^!3b?^V;a!n z=Rl;@Ak+LRC_FrFdg^MMQF3Fe7win{bJUq&;x7(RqA!!xv|gVfj=Re2D?AI5RW)1- zW7>U)yi*a{Az{h?=o?5}&a4==v|wF(siE^ie^Hw@!-(F1j|%Cj-;b9Skp{@baH*Z! zkIN=qJ)OLHMMV@kxu7=TdVfC48XXvA^-9+Ou1g=N;SnsLPFwSGSY(aBIE6P=Hac z_L)3G%T@gn{Dv?Dn#SC(Wq3VilfC{av1gAT0$r6f$O_<1_=e08E)HIUlk4H_nwRM1 zLmDqA8g3gl+rOe>ip7YZI2LaviHc2*FnpnD5h+t)#`=D!TfW=}-fwzu;|$m+t6>^C z%N>70sv|M{+qa6ehVOVq-Ee@32)oovCvn+A8WjpG#ewu>Ms-#1ar%q z`j-ajISmWaQgC$Cof2d|5v{y;{j*D5$OZ1lAJ+-ki~M0B3;RE2$I2JQG~oYwZ?H3YXOWR2k^4GMzo4>~rV!!O>iU zx`Yd(k<-GgqTkc#w<31+*LsYBd6&nP1rGwO^~5i-&BB($HP)k~#^S`3pS-2;9-*0q zSJOM0Ld_eSls9bn`W&XdN!jY13`?%Odgo7U??5*>oNdU7b2Zw*qB80%h&~57PcEdF zSiA)t438J{5mKhU_4e8INotZ7iJo~ixjcs*h5wS)%?(<4twxyK5;^i=+`ffvmrm4` zr?NQ|sOoH#Q=U3;lhmRBhEc3U+Cr^c>Hp4}pbTMlI~sw0gI+q2i{S-5jAlx|u=iK2ZTy-DL zI6Osbr_dDsNUL!p2$G&u>WuuBV__6j#-Ef!DE1XOFS`9cz6buZ-9UGEn(<&4M>ERZ z?Uw+U4(v7jlzzGzfw#rjN=#bO2b3jNsl8QeH^E^#kY?5rgiC#iq}u1dWjIUBfC=`{ z9FEyRRmIU#D0W>ReOPv7>m>ui@dmsjzm{#5>52f5XStjz;iy85p^z-9g#hc8llcnU zu40VlxM$7WDHVnxsY+&+)hy5Q-Gxb0UbwhW*V-W*Dc1lJ3t`7G9nA(Nmd~y@2ZZjF zNvuQ2y{WT!-1?#zUQ#5d2VT~Qt7`is!Cs@v=MNypbp}ljoW5vbQ{gfk-Whl*5!6hD zpPf05@vXI(NqSSKHNR!HCzlo@KC~SpdkfFkS1>0M%@ggVp*6E4PU)$M6cPATq(ujg znu~f+Y#am)K1yMVntyGZ*paEUv9fn8oKdnGSZh@(nQ1b}L?-sn<(|ssS$o2>W`Nrq z;(?oS(NDnS*7yLQff-_W_oKuur4_$Jl6;cv+bGk5_jol`2! zdWK_TkxG<$N-lzzCYoOl3bb>a3aho~dPJrla1Lc?e#iS~k!578Xp0Ymi@MH_mgM1bDg4dt|a`g4pYDxBXkH zfh{Fvsl!?JO)_}_z0wUl9=T6_-!Xjl&I}8)Z-A609DvT=T0GM&1kwf{&7`bav@1zgF9j0;39EYi<+F#1aK^k7GjOY zhfHcjrr1D*5Hqj(ia2k@t6GpwMGI(b6UNt;(u$zi;)A-SW@we~;>o(%m3MZ`irx@1ugmHs9Wdlc;3GM@~KHt7$O z{0(-hO5_#pyPrmq{^)8hV{JyxxNFd1_z&8$75znKz)z6d(=Vv~<=9h}3BQ7i?BiO@ zh+Sxrx^)FGB;ggVb)SH1A_@yua76lTt5i{!f11G`lrU~Oe4CAgON6f$4F0tY{Hqw` zg;MHhBA4S`J`Jb<11V%UHx_08eIC07H2Yo)3?$C5p?`*-yhWuHTS5rDe9MhD*MN^dpKO&rSe zeI^fcHD`0-*=*=D3Jne>WAw_Sh+W(0%_U|lt_@5DXBTurRb~+_{S*!g8Q>}Ng`sTZ z&m>8Qukcq~2=2D+#b9US18+oMjMD8Ac#~Pd2XaQ_Bo$RTt!b5kuddacX(R=leab0s zU#MK#<)W}ZH}@v@l-@l#LC$YolR$sBy0ew3T4J+0kxCe1Aq|3r{M4Y*-@bQ)D#Tzc zJ-+y2JS&VT*rr?SYLq_;vubF6vSVYEY~_}0^D9KFAt?3gyGiy@9uY6pU~;=>LO@Mg zWz+1@V$leUQ69afN!;d(*8QbR+4#)<0u z=L+%O3?!zleUAm7dt<+{wyP|7V`qL<;;R{atAV-eSAjXr>Z+pEF09OlK85TU7O?|l)r^)NrwKZo&1Fl-WgR?+9W6x7C`tyS9K+< z6uscu*>Kg4w#UBE!KN__ltWm)+J+YkwA5M>L;rC20ub5%d0WoF7n<6nW0Jy}lEq23 zKc{HKw6_LVN>pV_NxH}!jbW@Ql7{hfkG7<6p$+$Aq2OYfad(fFfSmVNuH0DIFO`dATw%(9gQHSqRs$?A&|19p{ z^&JX;r*`)DRp2H4BO$^f?`t8-fV;*FTz(5Ij zs=vUae>(9|hH&z-?~d+|Fc!iCpQC9Wbeo_X3529cTIpb((3?UD$j#sV!Fh0~w0Wb{ z{Va|oN!Nai+#eTAloax2V+&y_%$-4WV4K^4;@-Q}(gRe}VfB4_+;n*JFWfjf*HG3* z2ZKdNc?&d9=#n5l$-aG^zHmSMLnfT=lIdg;eVr5n1$p8Y>vIRxTASSri~kwn#~rPd zR*p$$0h(x;USsI1kEx58)(PtSjqYGfYnQL88@M2V10bcw>r(rW@dc?wdGrtZ@lAV5 z^|}ad7^~1RMVTN=gX;MWyI(@@LxnNDOJW{^Os|K}+qQz}U-n_04lbRD!)Nox# zGWn99?zZE@?Q(Sy3>W9p*FtQcRV+U_)69m|<1n8CEK%tV0cFI}9AWatewEhb`*hA- z4I>H4*;A@~j3d^ZPWgMH`s_iZ;izLAD6GFKJ^DCWc#l6%{E#PNS9Qsxb;~^cQ7s4^ z_r)NLVQ$+$|BOO?mOVxcg0WvAmxx0kQLkxtfBUZ~(p*U&7yQA_WNAf`SKk;;ZXh0$ zo^s7MrlC*m^!u?ZDj&?-V}1rbSmw-Dew}YKfnW?e{LX;o{B0ICpWM>|u2PBA{!vr(c}r{v^3i$_*Z>?Gm0$Y*)dUeX~m39T0AiFL$s+LUZ@>Aaihgi z-1jX-!e#{SGB17v-hgDs$SJts+Mv0hptZOI&6h#a>Pu55T(J}}T)gVb0vE!mDaRCC zBJ--4K`uqNOmeulfOAlKo=n|0DPn0vzDnWVKO7HERwUgF<_fx<(TUs3 zKT5(h)}r}tm{9U+QR(v)wU5qG1@32|=3Z0p)qc`cViDC8S0L7oebH?r zY)p{G!Z0@!>M+~Pj10skfO3kb32T}r42h@X3S2KKz{w3#F|?JWlkb4CB_I-hsx$BK zX7V_2CYG_&$;_`_`=0q~@RhtLl?C%+e=BWcu#UV?Av8c?$=zuepcl$o#on>=ltWE9q96DO+QbQ2{_SY81NQn02G-fjgJxZ;{_b#!2a zADRz)WtHl2D3k%i-V!gNw|=spnu;b8Yqsu^EQih$TH20&4EO-e2Na!^XeG_Hq(7G6 zQ@zT2@PsA9xA=x_uk9YCffTKB8ps&!v@eF>KwX^LIr*y_FxQxrMGk)*=gy)bX+~V9 z`6AnfKwjFy5s25UB&pB1Wv7x=YZZ|4>gM1R?h`KD;|*y=nWH9jIXj+fsF^xv-Q8@@ z{Kj$du_STIlL}y1lgp>+)ba5~k$t7%Es{$zI1XoTb;#gdB|J|FT` z>Q{BEHg#-?c$%Kgtv+^Yeop(CYT`!>`S7J*Wt)z4k$+*wF^4(xJ27=NzsC`WYP6yh z+;j035n>x9OQrmXF2eZWktwwK|CRMsQE_d}){VP0Zo#E-cY*}>;O-vWA-KB+cP9`C z65J)Y69^DoLh!~Kr+;$p`TjfZy$^dm?ESFT9#u6*%{i)Sl~fJUOptq}xQLd|0tS$@ z$!|W0`36W6v8GRU52d;(?rH_)8U-HNT2U!;EZh%3(*Ex)C9*!)Y zZR&1{Asj-$-+%@n&gVBG$f_$cc}>Pxr`MF#Y~Swxyi`v)!7g(+B`e4eccoC5b8$n~ z;=ij24u{Jhh#!vuE3xguxfzak?g08W*dD7lB~8C*llTYT^SM~}sWVlhRO4PUU&}lW z09G3gUlRp50PZWJ8#+hbs23cP)r@hQBQ+^!wrXHG;ux26f% z&2VlTx0|YN0>l>;ZH(llc?Zj1r$5|F&a218 zul2276|3ZjB>7wd%&K-o!O-m_P=c&i}t7(T1LdXD)w`tCA#+G>}?g_ z>wd(oz!Sp@v6%wnO>PBBtOXO&N-vEee0s3LVxqWx5~plN+;_tJwIwJZNgj?aXy@$1 zf^0C??_s43-Rdsg@4X}SYTQf#=TF)+cVIZ*r|O>7s9l)u5z>Xoo@qG%B_R`aTwT1S7N`r_++R#gG_m@afT#_`@_C%K=n(EG7>`qm zYVP4w%uD?c9J5Zp=?jjLxrL1s(clN*D{(qg-JKI%q)_vmlg58R*moh3Em*T0;>a@_ ze+m5%ooB`~FS9CAC}*%=w#bc zI)sErH6KsoIZbK?c&O&RSL&LBTF2Utaq4`{UpwI@<2M?qm8UDJsNV^G7?6~I&NQ4t zG+^lv1>n77L~5l?bV61pR*>%A%um}j(wZ$dbTabH^$iT|#rPs3LdCozx0by&Pu14` ze6>zyn^O%DCQYG%*r=IZ!BCgBuf-a5S!zx-b%Tg^%KLyXv0%>6hl0S#8i5iVanoF8 zWY@`eC39&zlr6U`o4JB@^`UgtqGKJI>nqkl`}HO18JVnCP{7dmOvl(sa%T<;=jK>Y zK5EfXt_tY{mWm+@u`u+oE~r5?xijP{TIY|O8#6+@KpM0{PZz-cwFqgx$Wvv|^AKB_*8_R0bM*009RdtcOzv4?%-R1?peAAV zhL!ZCPGO46J;}P5bL|I(j50m)sx+Uf3IgS5n)E(3s~kC*;3?xrkFz>>?LNV<81aiq zdf#vh`mO6ZAVv9eTe;JVgj5gjNR`c>a#sH|rb}#bHl>NtquS=7T7mz8fJ@exl&fqu zR3s~*K(Oy-mfP<9?QpQ%(D#hwz*lB_AfM~rS^78<10cjiNOG6@c({qvy`fq%864&c z5?1lNRZ{B{VagbGBPn-C^-71~nN|6VYTR3|l*CP`AEXwzzydWqR_SPLyJNLtcV1-%qZgVdM{z5pot6yJ+qQhIwcrU|Q4S%E! zTF80(sU*@T^yh#wAJz|9MkG2_#}3QA28P?{xYg2AUgZNJj)y<+ zp>rF*hq(w1A)y8A?>MQc-S=suh@YwnNVSmEgN101{KD0RJ$CJ>%5xiTc~`!Y;7(Dn zyA7j1k}#ZsTN@IHnWWOx>tP6G+&Q|BA|wf%_vaiaDM~q)t8*{XxH65D{{AM=<#jcd z$c6mNrl_DV)mnc)0IV3M2UyEP9B)8K7u+u{OcfnT22xI1E1zKAE#UE+ZJfZjfxWwX zgImqT_$7m_pgi?qBlzH_?;YW8!isc3E0G&3kNXN(K%rZB1Z}1M)yGL4^R0eVPbn+E zgqhlcO}A|!-{1^!Z{#PV7g&G7Rb^=bTc|N7P^<1|rsDfS81O)O)ksrn6&oud{)oqx z6$SD)%lJ=o1jwaUAbA+4>F%;>Bl(q4t?k`=)U*9lq5wFePUmKlF>D5kln?w2cBh;2 ziYhgSCG*iRtUnaWqOU`YuvG7qD`$S_A|BYWSc__U`v=x5wOLH)t)h@Js&Xzgi;X$G zC)}i%6IQNc$!fW)4RZ}r)A#!GjA+AQUfXi^aG7`4RW#W4+9Ra=WtLCdh_W;OD^1N9 z)}977@^kdW80Sx=iU6h3&z=l-<{dMA3s)Fez&2K!M3wqLOva_7h_x*V3P@FpeF) z1$=sFlz#<{hH4eFu_Fm4^G4dh5>uf2e7nm842ssDb&jRkYRFO%vFCo>G)n zEG0!t4nOZokV@L#;M(3YfQC4lNu`MZXs>-`fDVZQynkzgUPFsH64Y+|k>zsa@Y7WU z`jn}D1+jz+t7S;nUNX!?e9_$pwAPC$o@w7*RX50Oj(?X*$SQ$V%mL6r1r(a8*ECyD z*gQ;HZhAm6KpGSTopdWh1p_`N?fU!p#Oxh_ACP-AGbsxZ1O^KESod~YNsVdlN9hE@ zW)2~5nrsh%iiGtRO{||0i+F2j&m9#`dc6BRz7{wR17l->3Irsia`>celDCzQ;eTNO z=i>GB!7v59|A9e*L46%n`qOwLM9#hkEF_3 z*|yun>A8A6XIx6ti(!c}c;11R&YPDah1ah0V)Va)x|jleEsV6FH)czV1J-X&^2B)G ze(HUrrSCc-x#G3sJ-FjC~i%9a%D3{HfIq? zqg~LW0wA;_=!$P8d@Nagl!$E!gYvVVvLfPT9EjpfbMAn`7tLI}wPrQbDY~?vZV57V zAke9`|4S(basE4Q%7JDW2MGhL;7;}&eN;wRYTC__OaX60HtCl_vV79Et-l*YyHhrzL3V3~xC31BC&G@7KKYSMl=>XD4 zlLz$6P6!C^SCT2}@S=lo2G^8C$kzG$13nl9|AX=jiwJ|@2Qc6(ben1E^rvrn>x}rb zov)gdd*t+-ejBB%%7y)Sdt0s68o-Ns^O;se+*nlEjMZY8&2 z4cgVqjwM!5sw!UOKw}X*X$krf$tixz7KovoSx7<9Gn+7SP^xN+j4WuYBf^=a~4i;0! z7QMpWp6}=kCpKXmEeb`_lm6RQzt+eMc6qXC$+k&a_ zq*_!mhgw#yG}@$-Et$bPYpayF`=)#GOMknRR4G!^I)R=G2oPn6o^)JII`|~}q$X6& z3DGb&$xfY(v`n3=z1=CJ=L==MP{wsQqV@0ZvEW7jv*EXNMyZ7OqbzNoi2T0U!8jw9 zEWJC3tQ`$%GbhRgAq`MPgFsjyd@DOij48|U+$hdN9e5xjVXneC#yG*cH<;Hg#ju6* z$SD98uU!I>RMHLZ3l_Tf>~-<&LEhG~c(0Y(!q*AYV0Yv33ZFLAqw zyd%%{W$}==zQa3yhA1c-b*#tq7xEh>yVuv)@A{s8cG$fU15VJbG`SY%f%t@<-rJ%0 zD3p`@rg;NEr700HTxS+X$O3{=cT@|$$L-^9hg?`0sbDmcj5L{ui<}%|;){sP=K6|* zZeB|?JG?$#vQvYNkrr{^x-kWN`=SU7KIj-6TfnmArn*)-t0mu7k`#Dkv^R9CIu${; z;^+xcSoy5fK%lc}>aDF)kz6XJwrX<39sPJLk)1ZnDMY>9q}F6z4`7qC)FQlly?;5j zTij;TE)5<<$I)h?f^}c8+z*LnUI_R|EVvbCy5s30oE&zD3vVFEhbe0SL7Iw7tA)3mYdJ#`LjtpOQ;avo&kT&k0@LzvXelrtY`Mq+pQ6 zv!Q){yA1GxotgElL(bF!Wt%V`cwDKtX)wlM0%Dru5AhCBS6Xime_*p;mA=7ux2v%tq z_)$igXP^d`02~BT`;o2*4@OPL@!HLP#7*qV{e>#<`#nv|e!>xEbM}UgLFk2<)%)^R zOr~>3Un_SU-_aybddf}}E2}=c<7QX4csKfQnZZ-=1weRpdHVJt`$xh>3nk)YFH0}M zbg(mhKC-1CnuH9};%z(uoP0wJ?qD{pomT{*g(lgb8|zq<4ds;9)-e5YHMA0(FxWB7 zL8&^*zgP~}`MUN=
    OW5#^y3#Ir^CLjM)+qJeDJW1zM6&-gG-U3+Sf)(KPo9E(7 z%gwJSqJV)EY=Ai7#WtcK_;pLalvMyH?eh*lvoQ-8f*xPsH*DXHu(s&1WoHPsX5YD! z7&+>wEJ`uTauT9gkJvpa>q2zCY`PKTI%>PVr}I%K5DU;I~9!eKE{OpMs@8vlY#zf$4LRoPNZ%RHs3 zaeHtvUV!y%;8ydMcOK}J9Dl5@>s?gnb?Rtc=_pbf)mN(DG0K(lvjNQX=d_qfpEksH z?L2HvqcJ5&~ereJ96CpiWT$o)_XP34X0xbZJ<;GJfsbA#u4sD_oIHlRPd0@1nwX9N=LyQ!q zB}|3m>BQA^WqS>6`g8Wyi^Itlbxdc2NhBa_`ITxeDIm%Blk3LaWnqBbt-dQ^IKqqf zJQCot2TA$)csPuvXLS>&RqFV7`MEq&u>^8{)3{$r)*h7orPfnT;NyU-!l zk&W$!So0r!k9f5YyDOvN_-XMps7O(YIAbB1vOM%Bxjx^=%LS;NQ#6#uF@K7EPl=pX zQ6SUp^g9^m9|#DCHOl8W3~Zv-j&KP~Gq=VqKnVydR;;~miT2ca<3C~3n`Ro1N5e!S zI#k(SdejmZMuASn!NYlJZ_1N&&t-blOBe2hMpHRSfRe0??IhC}gP#B+`qOE~sl@%gSQcUW zwQ$aCl-3B-u6(k)t($ zoF-vokAuNKs;~JFgzq zqMWLQFEJ>TfRaSa6Qw^zCT*cfxjJK{>W{Y89bqchnp~X^rpNNTT4G~Lvph=j9HHN` zI%TKqrD0FbOKC9cJx=E=0V0D1Jsg*RdHCy?;H}sChH#0T9Qkc5dJS0Qp#Q|M_w%gO zf5mWwy6=>W<3`|!4M&r1CK@^V$H)$vE9o29X6J8E0TV=;0V9gfe)-CREPPHRXvVEx z!706#zI2n;SR(_|0K|+ z@0HJ3bKRDuVP1n?APNy0;&z?}N!88r(pyob(yXe*7T4`pQC!4`0x?uMQf9IzPD*35 z^RG*Ixdb<hjF+&ew_OWhy@2Ve@pK224;HYUJygLfX4pQsC{b4*3SFcZ7Bqrs8zC zir2#`KV6f>SkEVvK9m)>nsxq;Fdo_d0$yYNrZ!`JkZU$0L~r@lO*@p}Kz%M{5_(CH zVbxE}{OGig*(hY+06J}aTyQNFsg5~%`qJ^cdS)6GAATbhk?LyM#;TN=617e=Tp*8; zactT_^N0S-h0l#0Y@s6djFxj}ip%q1LDAWAFp|!W2NMH-RB*=m)2>Wm(b&g{gJy=G zd7lV3#LV2z_?9C19eijedf`7e+F>!?q_r4mJn3_%85|y)_T=0o_uNbe?t&BZ!q zZ$GpZ{nroU{IpIB)3p^Scp?oc5hg~b-pgj(v#%?mhG$9oP`m^oJ~IDV3)`|G+f{9I z__|)+q|@c4;k*$QWKb5aDr)2DaMktFr(e)~Yr{!I_To-@0&^^0lUFWQ?NKWdZftx^ zWHG0$L}+Q!pA!?BibErvaQoyn=8#?c`#gz*D|_iHTw}7a&v>S1Hym81B=m=TKXJ?Z z)NN*pTzz|Z)vuzh-pvwI6Cr-S3TT!-MkUZyXXG*DnnT+KQuxQ?(G+tQNLI8=7-L$X z=^9>AnU;=Dz#NSnZhZY|-8f~dpN6mNftqd0t-<1niL?#h9$x$LudNTrqFnt&<% z=ZTvn_|fjcTND1{)d6OA=bXZM#fr!VYQMes7EfFNiF>`I_EJ+T;@J3bIH2iX@8_0$tS7m;MhP@ipJNH#pJT zLW9l~tGXKdF~m}vBGxE+TXgWb?b|J-QBx1A4S%7huza#ruj|YVaslH zjlnc8;?Ja@%)?7~ZUm*7^j;db^sobT`ulOJ;kpulNpCO07e9rj$9)^)y3VD8- z2RXC3LD4s^^f%YMc*1Un?RzY3^HL}HyEQOA$$X-jKq!}fi$8YOQv56woS=Ud4^37#B3dF-VEfvx1&J8R+G18d*Qa5mzzQSm^&J{1m_Q_ffiVdW(COBJJKtYaAM&BZmLoEoLM~fnQ=7^e4F#&Q^^%oUHOz^(HRispUAZq%_ygVk pksK)x>E3%2`UhWN+!xlq58iq+Y;}DIt_5_45b8VAe=HmF{{Z-)J=g#M literal 0 HcmV?d00001 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");