diff --git a/project-data-room-consent-ledger/README.md b/project-data-room-consent-ledger/README.md new file mode 100644 index 0000000..664bfd5 --- /dev/null +++ b/project-data-room-consent-ledger/README.md @@ -0,0 +1,28 @@ +# Project Data Room Consent Ledger + +This module adds a User and Project Management slice for project data-room access, object-level permissions, and export consent. It is self-contained, dependency-free, and synthetic-data-only so reviewers can validate it without accounts, SAML credentials, ORCID tokens, or a running platform. + +It covers issue #11 by evaluating: + +- institutional, external, and anonymous-review identity evidence +- MFA, ORCID, SAML, and identity-escrow requirements before access is granted +- project visibility and external collaborator sponsor requirements +- role-based and object-level permissions for documents, datasets, and review threads +- restricted dataset download consent with IRB, data-use agreement, and export policy evidence +- immutable audit-chain and export-packet digests for reviewer and institutional records + +This is not another broad RBAC demo, offboarding workflow, identity merge, or anonymous-review escrow implementation. The focus is the handoff point where project managers must prove that a collaborator may enter a research data room and export a restricted object. + +## Local Validation + +```sh +node project-data-room-consent-ledger/test.js +node project-data-room-consent-ledger/demo.js +``` + +## Demo Evidence + +- [demo.mp4](demo.mp4) shows the problem, implementation scope, access decisions, and validation commands. +- [demo.svg](demo.svg) provides a static reviewer dashboard preview. +- [requirements-map.md](requirements-map.md) maps the implementation to issue #11. +- [acceptance-notes.md](acceptance-notes.md) lists the reviewer checks. diff --git a/project-data-room-consent-ledger/acceptance-notes.md b/project-data-room-consent-ledger/acceptance-notes.md new file mode 100644 index 0000000..48db11b --- /dev/null +++ b/project-data-room-consent-ledger/acceptance-notes.md @@ -0,0 +1,11 @@ +# Acceptance Notes + +Reviewer checks: + +1. Run `node project-data-room-consent-ledger/test.js`. +2. Run `node project-data-room-consent-ledger/demo.js`. +3. Confirm the valid scenario approves two grants, verifies both identities, and emits SHA-256 audit/export digests. +4. Confirm the broken scenario holds unsafe access when MFA, ORCID, expiry, sponsor, role permission, and restricted-data consent evidence are missing. +5. Confirm anonymous review identity displays the pseudonym while still requiring escrow evidence. + +The module uses only Node built-ins and synthetic inputs. It does not call live identity providers, inspect user secrets, or store real participant data. diff --git a/project-data-room-consent-ledger/demo.js b/project-data-room-consent-ledger/demo.js new file mode 100644 index 0000000..4a70b25 --- /dev/null +++ b/project-data-room-consent-ledger/demo.js @@ -0,0 +1,103 @@ +"use strict"; + +const { evaluateProjectDataRoom } = require("./index"); + +const room = { + generatedAt: "2026-05-17T12:00:00.000Z", + identities: [ + { + id: "pi-morgan", + name: "Dr. Morgan", + affiliationType: "institutional", + links: ["email", "saml", "orcid", "github"], + mfaVerified: true, + profileMode: "public", + }, + { + id: "external-biostat", + name: "Dr. Patel", + affiliationType: "external", + links: ["email", "orcid"], + mfaVerified: true, + trainingExpiresAt: "2026-10-30", + profileMode: "private", + }, + { + id: "blind-reviewer", + mode: "anonymous-review", + pseudonym: "Reviewer B", + affiliationType: "external", + links: ["email", "orcid", "anonymousProfile", "identityEscrow"], + mfaVerified: true, + }, + ], + projects: [ + { + id: "project-metabolomics", + title: "Metabolomics cohort workspace", + visibility: "institutional-only", + fundingSource: "Foundation cohort grant", + }, + ], + objects: [ + { id: "draft-paper", projectId: "project-metabolomics", kind: "manuscript", sensitivity: "internal" }, + { id: "cohort-table", projectId: "project-metabolomics", kind: "dataset", sensitivity: "restricted" }, + { id: "review-thread", projectId: "project-metabolomics", kind: "discussion", sensitivity: "internal" }, + ], + consentRecords: [ + { + id: "consent-biostat-export", + irbProtocol: "IRB-2026-077", + dataUseAgreement: "DUA-METAB-2026", + exportPolicy: "aggregate-results-and-model-coefficients", + }, + ], + grants: [ + { + id: "grant-pi-admin", + identityId: "pi-morgan", + projectId: "project-metabolomics", + objectId: "draft-paper", + role: "admin", + actions: ["read", "comment", "edit", "share"], + }, + { + id: "grant-biostat-data-room", + identityId: "external-biostat", + projectId: "project-metabolomics", + objectId: "cohort-table", + role: "admin", + actions: ["read", "download"], + consentId: "consent-biostat-export", + expiresAt: "2026-06-17", + institutionalSponsor: "pi-morgan", + }, + { + id: "grant-anonymous-review", + identityId: "blind-reviewer", + projectId: "project-metabolomics", + objectId: "review-thread", + role: "reviewer", + actions: ["read", "comment"], + expiresAt: "2026-05-31", + institutionalSponsor: "pi-morgan", + }, + ], + auditEvents: [ + { type: "workspace-created", actorId: "pi-morgan", targetId: "project-metabolomics" }, + { type: "consent-attached", actorId: "pi-morgan", targetId: "consent-biostat-export" }, + { type: "grant-approved", actorId: "pi-morgan", targetId: "grant-biostat-data-room" }, + { type: "anonymous-review-opened", actorId: "pi-morgan", targetId: "grant-anonymous-review" }, + ], +}; + +const result = evaluateProjectDataRoom(room); + +console.log("Project data room consent ledger demo"); +console.log(JSON.stringify(result.dashboard, null, 2)); +console.log("Grant decisions:"); +for (const grant of result.grants) { + console.log(`- ${grant.id}: ${grant.decision} (${grant.role}, ${grant.actions.join(", ")})`); +} +console.log("Export packet:"); +console.log(JSON.stringify(result.exportPacket, null, 2)); diff --git a/project-data-room-consent-ledger/demo.mp4 b/project-data-room-consent-ledger/demo.mp4 new file mode 100644 index 0000000..6e6e112 Binary files /dev/null and b/project-data-room-consent-ledger/demo.mp4 differ diff --git a/project-data-room-consent-ledger/demo.svg b/project-data-room-consent-ledger/demo.svg new file mode 100644 index 0000000..f7e3fda --- /dev/null +++ b/project-data-room-consent-ledger/demo.svg @@ -0,0 +1,38 @@ + + Project data room consent ledger demo dashboard + Static preview showing identity verification, grant decisions, consent evidence, and audit digest status. + + + Project Data Room Consent Ledger + Issue #11 slice: identity evidence, object-level access, restricted export consent, audit chain. + + + Verified identities + 3 + + + + Approved grants + 3 + + + + Held grants + 0 + + + + Audit root + sha256 + + + Access decisions + grant-pi-admin approve admin read/comment/edit/share + grant-biostat-data-room approve admin restricted download with IRB/DUA + grant-anonymous-review approve reviewer pseudonym and identity escrow + + + + Export packet includes approved grant digests, held grant IDs, and final audit root for institutional review. + + diff --git a/project-data-room-consent-ledger/index.js b/project-data-room-consent-ledger/index.js new file mode 100644 index 0000000..8cb5bbb --- /dev/null +++ b/project-data-room-consent-ledger/index.js @@ -0,0 +1,264 @@ +"use strict"; + +const crypto = require("node:crypto"); + +const ROLE_ACTIONS = { + owner: ["read", "comment", "edit", "download", "share", "admin"], + admin: ["read", "comment", "edit", "download", "share"], + contributor: ["read", "comment", "edit"], + reviewer: ["read", "comment"], + viewer: ["read"], +}; + +const REQUIRED_IDENTITY_LINKS = { + institutional: ["email", "saml"], + external: ["email", "orcid"], + anonymousReview: ["anonymousProfile", "identityEscrow"], +}; + +function stableDigest(value) { + return crypto.createHash("sha256").update(stableStringify(value)).digest("hex"); +} + +function stableStringify(value) { + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(",")}]`; + } + if (value && typeof value === "object") { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +function asArray(value) { + return Array.isArray(value) ? value : []; +} + +function normalizeRole(role) { + return String(role || "viewer").trim().toLowerCase(); +} + +function actionAllowed(role, action) { + return asArray(ROLE_ACTIONS[normalizeRole(role)]).includes(action); +} + +function evaluateIdentity(identity, now = "2026-05-17T12:00:00.000Z") { + const links = new Set(asArray(identity.links).map((link) => String(link).trim())); + const findings = []; + const requiredLinks = new Set(); + + if (identity.affiliationType === "institutional") { + REQUIRED_IDENTITY_LINKS.institutional.forEach((link) => requiredLinks.add(link)); + } + if (identity.affiliationType === "external") { + REQUIRED_IDENTITY_LINKS.external.forEach((link) => requiredLinks.add(link)); + } + if (identity.mode === "anonymous-review") { + REQUIRED_IDENTITY_LINKS.anonymousReview.forEach((link) => requiredLinks.add(link)); + } + + for (const link of requiredLinks) { + if (!links.has(link)) { + findings.push({ + severity: "blocker", + code: "missing-identity-link", + message: `${identity.id} is missing required ${link} identity evidence`, + }); + } + } + + if (identity.requiresMfa !== false && !identity.mfaVerified) { + findings.push({ + severity: "blocker", + code: "mfa-not-verified", + message: `${identity.id} needs MFA verification before project access`, + }); + } + + if (identity.trainingExpiresAt && identity.trainingExpiresAt < now.slice(0, 10)) { + findings.push({ + severity: "warning", + code: "training-expired", + message: `${identity.id} has expired access training evidence`, + }); + } + + return { + id: identity.id, + displayName: identity.mode === "anonymous-review" ? identity.pseudonym || "anonymous reviewer" : identity.name, + affiliationType: identity.affiliationType || "internal", + profileMode: identity.profileMode || "private", + links: [...links].sort(), + verified: findings.every((finding) => finding.severity !== "blocker"), + findings, + }; +} + +function evaluateGrant(grant, context) { + const findings = []; + const role = normalizeRole(grant.role); + const requestedActions = asArray(grant.actions); + const identity = context.identityById.get(grant.identityId); + const project = context.projectById.get(grant.projectId); + const object = context.objectById.get(grant.objectId); + const consent = context.consentById.get(grant.consentId); + + if (!identity) { + findings.push({ severity: "blocker", code: "unknown-identity", message: `${grant.id} references an unknown identity` }); + } + if (!project) { + findings.push({ severity: "blocker", code: "unknown-project", message: `${grant.id} references an unknown project` }); + } + if (!object) { + findings.push({ severity: "blocker", code: "unknown-object", message: `${grant.id} references an unknown project object` }); + } + + for (const action of requestedActions) { + if (!actionAllowed(role, action)) { + findings.push({ + severity: "blocker", + code: "role-action-mismatch", + message: `${grant.id} requests ${action} but ${role} cannot perform that action`, + }); + } + } + + if (identity && identity.affiliationType === "external" && !grant.expiresAt) { + findings.push({ + severity: "blocker", + code: "external-access-needs-expiry", + message: `${grant.id} gives an external collaborator access without an expiry date`, + }); + } + + if (object && object.sensitivity === "restricted" && requestedActions.includes("download")) { + if (!consent) { + findings.push({ + severity: "blocker", + code: "missing-data-use-consent", + message: `${grant.id} needs a data-use consent record before restricted downloads`, + }); + } else { + if (!consent.irbProtocol || !consent.dataUseAgreement) { + findings.push({ + severity: "blocker", + code: "incomplete-data-use-consent", + message: `${grant.id} consent is missing IRB or data-use agreement evidence`, + }); + } + if (!consent.exportPolicy || consent.exportPolicy === "none") { + findings.push({ + severity: "warning", + code: "missing-export-policy", + message: `${grant.id} consent should name the permitted export policy`, + }); + } + } + } + + if (project && project.visibility === "institutional-only" && identity && identity.affiliationType === "external") { + if (!grant.institutionalSponsor) { + findings.push({ + severity: "blocker", + code: "external-institutional-sponsor-required", + message: `${grant.id} needs an institutional sponsor for institutional-only workspace access`, + }); + } + } + + return { + id: grant.id, + identityId: grant.identityId, + projectId: grant.projectId, + objectId: grant.objectId, + role, + actions: requestedActions, + expiresAt: grant.expiresAt || null, + decision: findings.some((finding) => finding.severity === "blocker") ? "hold" : "approve", + findings, + auditDigest: stableDigest({ + grantId: grant.id, + identityId: grant.identityId, + projectId: grant.projectId, + objectId: grant.objectId, + role, + actions: requestedActions, + consentId: grant.consentId || null, + expiresAt: grant.expiresAt || null, + }), + }; +} + +function buildAuditChain(events) { + let previous = "0".repeat(64); + return asArray(events).map((event, index) => { + const digest = stableDigest({ index, previous, event }); + previous = digest; + return { + index, + eventType: event.type, + actorId: event.actorId, + targetId: event.targetId, + digest, + }; + }); +} + +function evaluateProjectDataRoom(input) { + const identities = asArray(input.identities).map((identity) => evaluateIdentity(identity, input.generatedAt)); + const identityById = new Map(asArray(input.identities).map((identity) => [identity.id, identity])); + const projectById = new Map(asArray(input.projects).map((project) => [project.id, project])); + const objectById = new Map(asArray(input.objects).map((object) => [object.id, object])); + const consentById = new Map(asArray(input.consentRecords).map((consent) => [consent.id, consent])); + const grants = asArray(input.grants).map((grant) => + evaluateGrant(grant, { identityById, projectById, objectById, consentById }) + ); + const auditChain = buildAuditChain(input.auditEvents); + const findings = [ + ...identities.flatMap((identity) => identity.findings), + ...grants.flatMap((grant) => grant.findings), + ]; + + const approvedGrants = grants.filter((grant) => grant.decision === "approve"); + const heldGrants = grants.filter((grant) => grant.decision === "hold"); + + const exportPacket = { + generatedAt: input.generatedAt, + projectCount: asArray(input.projects).length, + approvedGrantDigests: approvedGrants.map((grant) => grant.auditDigest).sort(), + heldGrantIds: heldGrants.map((grant) => grant.id).sort(), + auditRoot: auditChain.length ? auditChain[auditChain.length - 1].digest : "0".repeat(64), + }; + exportPacket.packetDigest = stableDigest(exportPacket); + + return { + dashboard: { + identities: identities.length, + verifiedIdentities: identities.filter((identity) => identity.verified).length, + projects: asArray(input.projects).length, + objects: asArray(input.objects).length, + approvedGrants: approvedGrants.length, + heldGrants: heldGrants.length, + blockers: findings.filter((finding) => finding.severity === "blocker").length, + warnings: findings.filter((finding) => finding.severity === "warning").length, + accessReady: heldGrants.length === 0 && findings.every((finding) => finding.severity !== "blocker"), + }, + identities, + grants, + findings, + auditChain, + exportPacket, + }; +} + +module.exports = { + actionAllowed, + buildAuditChain, + evaluateGrant, + evaluateIdentity, + evaluateProjectDataRoom, + stableDigest, +}; diff --git a/project-data-room-consent-ledger/requirements-map.md b/project-data-room-consent-ledger/requirements-map.md new file mode 100644 index 0000000..3eb8a07 --- /dev/null +++ b/project-data-room-consent-ledger/requirements-map.md @@ -0,0 +1,35 @@ +# Requirements Map + +Issue #11 asks for user and project management covering identity, researcher profiles, project spaces, permissions, and auditability. This module implements a focused data-room consent ledger inside that scope. + +## Authentication and Identity + +- `evaluateIdentity` verifies institutional, external, and anonymous-review identity evidence. +- Institutional identities require email and SAML evidence. +- External collaborators require email and ORCID evidence. +- Anonymous review mode requires an anonymous profile and identity-escrow evidence. +- MFA is enforced by default before access can become ready. + +## Researcher Profiles + +- Identity results preserve public, private, and anonymous display modes. +- Anonymous review mode returns the configured pseudonym instead of the legal name. +- Expired training evidence produces review findings before project access is trusted. + +## Project Spaces + +- Project records model scientific workspaces with visibility, title, and funding source. +- Object records model project documents, restricted datasets, and discussion or review threads. + +## Permissions and Access Control + +- Role permissions are deterministic for owner, admin, contributor, reviewer, and viewer. +- Object-level grants evaluate requested actions against the role policy. +- External collaborators entering institutional-only projects require an institutional sponsor. +- External collaborator grants require an expiry date. +- Restricted dataset download requires a consent record with IRB protocol, data-use agreement, and export policy. + +## Audit Log + +- `buildAuditChain` creates deterministic chained hashes for project and grant events. +- `exportPacket` summarizes approved grant digests, held grant IDs, and the final audit root for institutional review. diff --git a/project-data-room-consent-ledger/test.js b/project-data-room-consent-ledger/test.js new file mode 100644 index 0000000..7e5fb45 --- /dev/null +++ b/project-data-room-consent-ledger/test.js @@ -0,0 +1,145 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const { + actionAllowed, + buildAuditChain, + evaluateIdentity, + evaluateProjectDataRoom, + stableDigest, +} = require("./index"); + +const validRoom = { + generatedAt: "2026-05-17T12:00:00.000Z", + identities: [ + { + id: "user-lead", + name: "Dr. Rivera", + affiliationType: "institutional", + links: ["email", "saml", "orcid"], + mfaVerified: true, + profileMode: "public", + }, + { + id: "user-external-reviewer", + name: "Dr. Chen", + affiliationType: "external", + links: ["email", "orcid"], + mfaVerified: true, + trainingExpiresAt: "2026-12-31", + profileMode: "private", + }, + ], + projects: [ + { + id: "project-neuro-2026", + title: "Neuroimaging replication workspace", + visibility: "institutional-only", + fundingSource: "NIH pilot grant", + }, + ], + objects: [ + { id: "manuscript", projectId: "project-neuro-2026", kind: "document", sensitivity: "internal" }, + { id: "participant-data", projectId: "project-neuro-2026", kind: "dataset", sensitivity: "restricted" }, + ], + consentRecords: [ + { + id: "consent-restricted-download", + irbProtocol: "IRB-2026-014", + dataUseAgreement: "DUA-NEURO-2026", + exportPolicy: "aggregate-results-only", + }, + ], + grants: [ + { + id: "grant-lead-edit", + identityId: "user-lead", + projectId: "project-neuro-2026", + objectId: "manuscript", + role: "admin", + actions: ["read", "comment", "edit", "share"], + }, + { + id: "grant-reviewer-download", + identityId: "user-external-reviewer", + projectId: "project-neuro-2026", + objectId: "participant-data", + role: "admin", + actions: ["read", "download"], + consentId: "consent-restricted-download", + expiresAt: "2026-06-17", + institutionalSponsor: "user-lead", + }, + ], + auditEvents: [ + { type: "project-created", actorId: "user-lead", targetId: "project-neuro-2026" }, + { type: "grant-approved", actorId: "user-lead", targetId: "grant-reviewer-download" }, + ], +}; + +const ready = evaluateProjectDataRoom(validRoom); +assert.equal(actionAllowed("reviewer", "comment"), true); +assert.equal(actionAllowed("reviewer", "download"), false); +assert.equal(ready.dashboard.identities, 2); +assert.equal(ready.dashboard.verifiedIdentities, 2); +assert.equal(ready.dashboard.approvedGrants, 2); +assert.equal(ready.dashboard.heldGrants, 0); +assert.equal(ready.dashboard.blockers, 0); +assert.equal(ready.dashboard.accessReady, true); +assert.match(ready.exportPacket.auditRoot, /^[a-f0-9]{64}$/); +assert.match(ready.exportPacket.packetDigest, /^[a-f0-9]{64}$/); +assert.equal(ready.auditChain.length, 2); +assert.notEqual(ready.auditChain[0].digest, ready.auditChain[1].digest); + +const brokenRoom = { + generatedAt: "2026-05-17T12:00:00.000Z", + identities: [ + { + id: "external-no-proof", + name: "Contract Analyst", + affiliationType: "external", + links: ["email"], + mfaVerified: false, + }, + ], + projects: [{ id: "project-private", visibility: "institutional-only" }], + objects: [{ id: "raw-participants", projectId: "project-private", sensitivity: "restricted" }], + grants: [ + { + id: "grant-unsafe", + identityId: "external-no-proof", + projectId: "project-private", + objectId: "raw-participants", + role: "viewer", + actions: ["read", "download"], + }, + ], +}; + +const held = evaluateProjectDataRoom(brokenRoom); +assert.equal(held.dashboard.accessReady, false); +assert.equal(held.dashboard.heldGrants, 1); +assert.ok(held.findings.some((finding) => finding.code === "mfa-not-verified")); +assert.ok(held.findings.some((finding) => finding.code === "missing-identity-link")); +assert.ok(held.findings.some((finding) => finding.code === "role-action-mismatch")); +assert.ok(held.findings.some((finding) => finding.code === "missing-data-use-consent")); +assert.ok(held.findings.some((finding) => finding.code === "external-access-needs-expiry")); +assert.ok(held.findings.some((finding) => finding.code === "external-institutional-sponsor-required")); + +const anonymous = evaluateIdentity({ + id: "anon-reviewer-1", + mode: "anonymous-review", + pseudonym: "Reviewer A", + affiliationType: "external", + links: ["email", "orcid", "anonymousProfile", "identityEscrow"], + mfaVerified: true, +}); +assert.equal(anonymous.displayName, "Reviewer A"); +assert.equal(anonymous.verified, true); + +const chainA = buildAuditChain([{ type: "a" }, { type: "b" }]); +const chainB = buildAuditChain([{ type: "a" }, { type: "b" }]); +assert.deepEqual(chainA, chainB); +assert.equal(stableDigest({ b: 2, a: 1 }), stableDigest({ a: 1, b: 2 })); + +console.log("project data room consent ledger tests passed");