From debabe274e4886e88c12505aaa286bf770de92f7 Mon Sep 17 00:00:00 2001 From: HunterCML <5335527+HunterCML@users.noreply.github.com> Date: Sun, 17 May 2026 18:49:55 -0500 Subject: [PATCH] Add project data room consent ledger --- project-data-room-consent-ledger/README.md | 28 ++ .../acceptance-notes.md | 11 + project-data-room-consent-ledger/demo.js | 103 +++++++ project-data-room-consent-ledger/demo.mp4 | Bin 0 -> 48145 bytes project-data-room-consent-ledger/demo.svg | 38 +++ project-data-room-consent-ledger/index.js | 264 ++++++++++++++++++ .../requirements-map.md | 35 +++ project-data-room-consent-ledger/test.js | 145 ++++++++++ 8 files changed, 624 insertions(+) create mode 100644 project-data-room-consent-ledger/README.md create mode 100644 project-data-room-consent-ledger/acceptance-notes.md create mode 100644 project-data-room-consent-ledger/demo.js create mode 100644 project-data-room-consent-ledger/demo.mp4 create mode 100644 project-data-room-consent-ledger/demo.svg create mode 100644 project-data-room-consent-ledger/index.js create mode 100644 project-data-room-consent-ledger/requirements-map.md create mode 100644 project-data-room-consent-ledger/test.js 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 0000000000000000000000000000000000000000..6e6e1123341a2747df91ce5ac95f83abad6c7e3c GIT binary patch literal 48145 zcmeFYV|ZlUwkTY&(XnmYww+GWv2EL(bgYhTI~}uQqhoh$TVLhf`<#33dG7!3&mPa3 zb9hY8Rii2a002a0E}jmS&i1wd04U%S0}G3xn=!Mk0~<2{faqdtZ|?>G0BmgCEKGp- ze*oAa06-`N00DeH{|Ejr1}OetywLx#{C`oP002U^i<6->P^sf${ZE@<|HJU#(SYs# zZ}?yB{J+|T0Q!JV|KmtzYU1Jyr0`5_oL&AM3K-!tF8IIujDTTlVrdA}5!ssj@3Hd% zZMFdF{r`B%uLKgHkL*nEF5hAQvOq8eS82hC(ue1!~{V2u?@g62ja?Q zAti`*5M%%V4DeZj^;`vG8-M^HElzgENP6BLA3wd39SofvfcUc`p9Rgx#p&O(Tm{@| z{{WV=%Rejd$$Z*ee(pdZpaBQ=xiX*dZ-1z8@WmHyGg{u}=n|H*#({Sym_ z|D*rceEesA{xuJu+v7j`<3H!)Klj7G&cT1~$A8Vkf9}VB%>%IhzwSrqPab$!{y8sr zz<0CprvM25em8%12Vg^h_n1K(|X9@#t=41-Y zp{iVMfmXnqry3ICC=GPZXzC1U1aCvswCVPPd= zV&h^mHev!Qq!@q(jPgq2lJsmu!e2#!mL{ggK!vEigQtzDnF|pM6B8>v3lj@FP-x-e z;=sem=$@R`Bc+Zft0*gKgsezwA3;bLn8w6S+^v9z~y<{>gRG%_^iXC?w>Y5c52 zCZtN^t(A~*c|G$U(sdqBwH+C{3 zvIQomrk}$CGW;y83`|6hpM&9NV&DLh4xfSl!G<3E9Na*Gvx}(%KO2#y18|Xm8v?k7 zz*vTk!1e!JO+Wwu;NM{y9s=O}c;5zlL|byWVz;OyA71?-TWs?*3@=$7)Dpz>nLq#c zqm-d!L#tRUf0X-=3@#S{CuyPoWf~?h4D8*eb}SnQllJ$Zy#;%{=1PFWHEz_WevNEQH2s2AguX&xany zX3dy^=M4oE>?Z6G+&{%IdS?``Jz&*Kt!Z^+EV6846xY|@;}J^V$1kq4TI*Aa{a$3 z;uOkqp991Mqh4}NBWYOq#=XlV0tcC}=0ZngwhR)n5vIuE%#j^cd5F;|4lMYB_4|@0 z^-t{tKFDX&K@ODp-oH>3eGCatVJJQuI+?sF>k~^u6d;t#!SAQo^4p@j^TIhNiMq%B z{BY2x3NT6W*qaDG=G~gpQTj`TF#8 zHsKFj0i<6?O{jRU!le1ULob@4*$)$2SCw5*ZjP<(549Vt9ba~4;oi1Xr=GD?U5aX& ziN3icuZ{;Ig%}Npr&0sxpfaw8k9e;v{WB~^PNKO11ExsqegoOG=oK@uXn!*tAG>j@fm?F9FU|%sk=}D zNK@9sO>O%c-VR9lx~p%gWo)%6*=IleYa3snvFAH@wWGECFk$85{s|^(*t*vQh{?Wv^Jyw}Z(qKM!mu;DvB(=yM5&Lo{8U$R5Y&a^z z=uK~o^sBe3fk%_+=qKW6ZHDgvho4S5*C8|KZp|?yjrKtiE7AtILSLCLMIVsj* zKFxm`Gn_l?tS%rkYn;>$$^gxjWBOJDC!QGulo{mezXnSAY-9|C)OlLvX9lv`#Q49) z-qFY^z+ImZ*Oj%EG%_UquU?!Jt>nu3D%|A z=zPCaPK>2`U0wmfZ)!h^!R_PZ<@{c}QX@Z=XlmT(GsfcdmFG-wB#DK!~Ct!9o~${!RLHp;9JWD`8PYfd;a@-^L+TUOX?yik3l3u5R^3swKiR zdSD6h-ui|;O8Ivi*?q(D+s!xWrwqvq5UL(oi zoP+rQGGVEBV}d8nSz1Y+rQ|sTOXym|-sOFbqyPAdn>?K`jTccuhm(qU(-XZMtmo-w zDHJq@Y0d9C^F6UqC9cfX-D2V1>={>pw_d@%tMUrI`77Jm?(Aa}DS2Sc#dVH~SeQUo z70CigrwS>+iZOMvP9Ty=z+}j-E7Mx6AZE~jdU4-bZ4&8D1&%)m-}(`0Sz7=l%#RGL z3TsDRkC4p#&25sG1ZJEHs=pbx(%^Lsr=)iSIvX25!E<=F6wwM1W4+XGPPJb;?ZQ|n zUC|b_nLV>t>`c;5L;4FVbi_4kES_YOU3r?Q{lm50!C7=uTtnu>&nT27=}h`~FBxTr z%ikGj-@^P>=sGAyJ%fd@2WpSTlmesn(OBJ`s1JT+)F(u2qmj?xtE7JcUyDCAd6e>| zzM!KZNlY%qLz*QKG#jfOR+TP8^ir2R`z2LwdoX9`<1b5LJy>LuksI*MxJ;dBI zGqNM>S5ZRkDyhRtww`MIa3Gn$;O}2>AE|?wM5R90>wNH7U&Lxxe{4Q?x$VC*9DkLO z0Cand%7tc|IxqijIK4|>O0*3s*O;lDQ@nL4ZWs|~EXnVd#NyoHarla@+=d)Q*szu=d5V8ZD#hyu`F_2 z;5Zg{#R^ODWFO68c5$?z#i%NWQNw8r~lylZF zl{ICEdP~|)hf-o*mkWRU#uXTbSUudxq&hTtHB9rvC4id5IF)w1e6JkhlH;V&5a^Sg z6Ry4Kih1UfBzeDDY#2hd))?<0XjEZvjF~mv-fP>i8oHPsuvu#ni_Yj{wZqhBpXd!L z27Qms11H(ZBFVxw30&e?eb|iY7Py#-(G7c(=tsIq=Dk07h&Ju3O#GmY$Lvsz5#>Lp zWw2zrS!Eo4)!BukL*~?xijR6PG9ltYkdE+S!F;t<7ei0xY}`AaRmYW2c{a;#9;x*n zym4^fh@+nyy30&Z1JiS^F;1@yu4n9EAI#oL;h`fd;k|^=w?EmHdPDi8ApSI=t91QS4~$(v3E_*>H|$z3 z`X<40*bwNVdG;>DQkgoAzTbEeDxBhdMp;qYn>seVJEh|D=J^?x)% z47ZPEhxTU%++uBe$(8TK@-}f~I3%%Q;k6z{n%0BMm|JyNQdZCpo?v0ZO0#w{QAHVx>Y&%c?CBI-mo^ylx4_u@qZM`1 zVnjrkzizzNJ${?&TfJGOvfJt!chHCLT0RY?a`KJBS+;lra~iE-$80;O$2W;|tEBI} zZtQozz!K&faV%O?FkQnKB&2z@4 zH^!X7g}$QYgJ?F#(kw!;kgeI9{CYo2H0qgc{kmno_{J-7?dFs<;@6o$Qu>FSlFKls znl!hoh3jU&XgNIDvzAhA(xj5oVq$Gyhv8`aHH5oV`tn&S&74D8X{UR@D`IB7M zVz&{!vwrHD8XXPy$sw(3Wi#1CjyvU$6kDgX7R8s%Kdmns@-mUL$)pSig&eGp5pVdU zxpF|(Oy^AYX4+h47OA?Z(m6p*c-mB8-<7o?xcyyyTvHJb{j1GdIxrWUpTzqAjk#u($N=J0lWnk4q!r*k<8*gs=9GqlKyGRu{(ACN zr_MqiL{crwC>NCP<)dk~Ncwaj;w=%Ov*4Ad{6|UVCG6#;SmvblIa|G=x5n%r&Mm@K zVqpQ^sT8!v$f6TKdlki)|F80bz>W?-*FDuImjGQqkiHUzzu3{e8_q8D5{)Le!^*Qg z1AkgpK0pT0+bXCzD6pS#x#U*zpqT`z?QmSGPl8?|^(7{o%0kT;LA&&2!o5a<=17+*SC{(fZ!9mJxvw0@R1Z_$F*Rq0Egv7e!isrST!Jp<*(ygY|* zp3?5L8CsQZtgfkJ;y>qtYeN$wwTC#p*p!qef3`^=rcsaxR7DG>e%;O9-Dz00# z3aS9DcF|6fj1dbkdryhqeKi?4>GZdQH@rGzLKMc>z3zrp<#t?-N7~k)x5`7v{m2)^ zf8Udv-D<)4SdCkM2|iaXV^-QoyHBx=1a23#?57WiZORi?Hyy`xTTomlEa7MFGS75K zjU_i?7u!{HV&jw20^Y_Ji!%`P@xpa6Tp}!feecX)O_^9TY$xAwEvAs8@1aYp^I&eo z91C+t+wuHxGX;u)$uJlQ@p#OuQFFSuRq|Vf^Tv|cVhi_Njr${Cu|X4K{|lWl^qkin zvy8mt+VThtW308@05@&#kMm#aZr&mb`h=k&)8nYw)D~!Ao$KgVeT)aK5G%)?>Gqh= zhWbLqDtEF4v;xIi{TJpX`VzEI&!@VqXhIfB`ZcACmR6s-m*V!NWf z9dTfF{iWqK3N6z^n(Bt>an4}TE^{mLaJ|&LL21m!h`0b=8{zkl<$8H=MS;T1h6lfP zicp|J<`C<8}dzlk}JknL?AJXC5lOV{R%eej$tl%jWNHdCLB%X^DvPj9Qf zoSK2uS7xFoN;kyyi@LcZOF7gmJ1B(aqSIE4zq%(=wHLTt1mbz8pv3k2-H1SsShf z?Ny$1&M@<2nYVJw8S%@$%h4sy6O}v&Me3m3?`B4v&TdREI~ybX;LA6YZw_y!l7~k^ zYvYijelm;(E`w^{T6|J!&Bd6!Z#GF$-tXN>l=`jZoqNk)#! zpqhFfA+1xYb2#skk1Y#l+H%c1df=?4Y}K9-Rg{K8l6H1Kgj3tN3y`V+b+_yFKeDH9 zmrtE>S%j{LX@QQ>IC9<%h7w+;_?8QY4R<3*th2Jl6uhuax-r!)5ClJYQ+1ou*_V$` zZ2&i+<1@=;t^(G(av_IOl(dYmUU`}E0^_+ABSF%(?QmFX6hq&v<0Joap2&JT!?C*{ zln!DIrSAVw#X)WMVX>KJBRd&IXy5bWI7*rRV)@CEBKM@gWMzEAEg>*PyDU=1_L`Dw z=G%bNADRMCI#l1&S)&xPn|uYU>pTgBx})jC#jJDL^Q)-2%+!U8@Nl8F>;niK!2MuL zUd)T`(zLaiAh{zrL3;JvD6=MizIgr4*dE`@V*;$2kM$0FBbRAVyK`C-vJIBO9@P`7 zfkySZ5*^ksrTb+aK5YFoNn7Dua=^h7Tyw1H#|2sT&kga~Utw_BMU^seW+yq4MXIf` zy1LvSGihAWutJZyjTN_+3pJ1(OLIuE(z1iNSsNCLalQoL9_r@r^6p7JGHQ@re8nSD zRF>TBL4G?Z%S{QLdje^6-mgWc(v?S2`_`EEgfM-+K{#^Rm#%Rq6y7W+|$@EC1fJk4<_iq6+=C;$@)=_^q&_R zm};Pjf=B8h)krK%WW>@#eybc7Ny=fx&aV@K=cC{3j}24kRhp3GDPnM|st?lUS}Fcm zqU9B4Bs->3-S+c@sfp>fwon3j zY6*>ZlHN^RE$Kv5@oX;VDv~iWEMv*>19aF7jyLCR<~8H_*Lk?UW)j0&hEd&nCAi5C zDFUeQ*E(GyBu{i5*sgF;91{gAny3?5hM6NCFaq?w8lHtNg$Rro-eraeve`AnyeG>5 z8sZmqHC zn!p-81$#AQ%Om}yUTYy1TGF{@^mPG4w~yJr1K z;o_XHT@8dYk3Rl`a3dubObWv&$YuG4=T2I~xSk(O;*NSZ;Iz|@6d1fU@fGMH z1*hkGEb?;tSdURH1Yrkt5$DeH?-p?BoX^QcnoInF2kdp2;3r?!sI4#yay*{$TS z2E?AI^Y5V)t8Xlf(2)Tue>`l3+|k^Kbg8@k@+xGKqxtI3_?WBKFn;&>OF)mz&br>g zwTOin8$nbq6!tY%Bfs&u&*t0_*)8-dSXyODV=za8qXxmq)_9D0O1Z!oc6mngK$y*| zyE)BAQwTk^?kg=st+!8~;kSgM4VB9-^foiq&{LUhhU|~o<_Pc1`2ae6FX^k*@0h3E zyH95l4dFVlpibVy0~~PTJvJ5eXjH@)G8zay;q4e`%WWl#Ci+$2L2IXlI|<#_Ez%C@ zI^SxoPr>t{I((b4>nKfNM_g?Qk|!i2LQbjgD&d_N$ED!CvYPYq;{E1FN0B z?NJt{OE>H8^Ett}>za^>#Kn*mN5*t@U)Yr_4Fx%u<;I&rUJc2nI2oIfJP5ed-4C(Y zjxUZH!3YH_T&hxG*en&(de(wVk%OBOrS|e*SRq@P=qSSk#B)cL(QPg^!t`IB{lJ}j zUcsa9zO@f+>slaTxWSxJxApXsj!y8e5s~Ivchfl7vYk@&{XEU)n-#Zox_#b86+v)X zy9ZT*){3*@Z-OxoOzhIm@8rj#HsEm+8a>q1;Yt2uH2!woJ%a3@b`Y5YRo44<>3t+? zL-1Cpxe~Eba@RizPYic{-S0>A@f`~!U(?gdMmuvb>6H|FRVw1KTW8>9#u+k5UF2`N z2+x{=CAVx(%h!WTxF6U!Z9d+u(x=c{cNjrZW84O&zv1IlVdc{Rq>UdFZsRd5*sApz zDWT#!^B{W=++)ZRGm?1 z)b-VR&^1>|>#B1n=KP7gZi zg6~vmM1)T?`lok$WoyYqTb^;LZ=4==S!cZkzozGWamF#~)u0J~Xk=cNEZcOdm$=P= z>!Dkk~>na>OPzl$-Itiy9#8YDR^E`>ybnD0-=|MyRdB(~k(?-o^Nx zsq?G7UwZ_e6I&vC*{B>up4zE{Z&^$cp#06Re$097mi;jz68bBE!)>LK&yI@aqDVx3 zKA5rFnbS21Ko&l6{-m;* z2`|aoT^l&(bsVr?hNI`zn;6Iz^?s!wV(t&r#F~?Z--&)D%%O`A50%0mO)V(=TojzI zH_#vD`|4k<{3X6Eqd4x2$a^@>@boM>kKQQgJJeBP%ppevEH`n~p*o|&ggeOPA*9%Qny}m_?`AgTnwMHy?ldok zD`z;aee&@s3=6g5Ka;O*MK5hPbK_tG77%-!DQ*%_R0k^U(>ZipQ!XdZTzu$USx7vJs2AlpxH9G*1{YT_5)+!|!Lt;g zED=(5l{(`t-mWcuS1kig7cX45QuTrQqT0^(o_CsB-;7}VY$9HjjAyw}hZIsMCUEH= z+O0lAuSn|Ie}J3C!}u*meEIOCfD50LWAZA($OEfto^UhTEvod3xhgwCM|yhqD=m(l z63Hs5T1C-A_yIp&>QDek>rzp+^a(dv8doFHY82BAv2g%pu9oMk4*dH0E zm%pp>D+zw3u~?}$e>`ZR(m&`ZGLX^3<>;xWIaZ^`UERd&#U~zGn$kvfOEz+WQ)b@P zKs*@-J#xnB>qaY_7C86x`=!-aw9pyGm*#)BwWnxN9(j?#D3&;`kuE@O=d|d+8c$ho zb|KGr%!&95vxcBK#K{&=$Za8|aR*za#N}(k2x19#l3tEqDxHXJhn*$$1b<=PKVX&K z<%{rT>Kie>Qql>)y$m0Jn5UvZ{3%fH=SRTQ-OBVPF=A1uDVv#La8N|QN?r$jucFF8)$_*e^E zh$~gAxEk}9A+xkNoE_q&U|6cjQH4%I?#M05)R?B?TCrD3Vys;mqhG5aMZsPe^b6pw z6PDX`R$qRMDID}dTbMp?6gz|(3MbQnnreXHr^%Lg$3IVv&iPfzZt`QP`a!1}rH9v4 zO6&>K+U8md^Ddx;zfL;*X-R-cH8sr8X^1@NG@avcnz*yRql$3-${uld1U9{Ni(V~E zKijhTDE6Id{&jk(rmk*?h@G3HYcE-rbmv9*^_p0uT1VY(V+S-(G0(7a5w2Al?Uw6)-S!!k4Kqm990I z`dXgMQ`d&1oozjGtc=N8W`F1{L-^CbZk#IZWr-ppL4vIaW;lx znQYR@LYgF<$i)W#R&p<6l56ZjdbB7+8^MxK@+J)*P7c| zR~;{E}qo)Q#3434p+H2T?b$h*mt zz{1s<(MZ3-`PiLBg9a%pf&XPx2G8VDfsa66fo8fz$RQ=(h!PALNVMz|{24a-A&>9n zpQ-?72rw(X0Ji!I6_iZt`QF;OZ!t=|@+ogCS^T0_ICJ%Ed70W{G47srI|j#awIU~Y z2l^}AQ6QID-)NS0`v;n}u=x3x=U?ZhI)}`j0yXc2Z2m|+Y^326O;5Z`?uPies!z<+ zrssV%ya#K33U=Jd?6zaLQ5DE$oJXUw|A;CGx1rfTXo=Cs(--H~bvcW{YZoYAxPuG+ ztHE3>`JHZsfWKY3og;TzugYkgI8e%rc~&4})LeK-2>62spo^Ay4ObaT*c3qt<2|N!O?Uvn7Am$)g&7J7o4{t1G9P;)%A2v%)hZmqFsHEcM+Cnq2s>}; zQqP0n7TojUZ|@GQ@a)R{G_56&rMnW%lUOIjJ3^3`k~Pa^8@}A>9+i3&9(XGVG$J+ zVVXcxta8?$T;x*!9O=WO=poTt`9)#CTaJ5j_Xg}9Wj4;)8JPwS8azpXKZjyHyMF6o z>lM|cb%CVML(+lJ65Hs0uM9Zvm3#VxHT*UdJn@a6K-zmfRPpbh&{FT}h`FKVaqqL9 z?z}PdD*Z1us(-Y~6XsetVJ^R8gkWhCvPF!IxhW~%1MV$%z}xd4t4)mpo_lMoWFgl| zjTfhipp*l`LYA5pYT4o#k;dFXGaYinxzg9_gM^F>RA)d(G!;w9#oDd5`gk%RjYu8} zP_~cBsM`liJqYjFG#EjJiyIwqbjqPh7?G2{_)KN>i9b$A5JcQvG^!;@js0;ijM(1n zCY^qQ_6*t8Z5`39Vh;Urw)NmmNS*OpXB`Lzp-y<6GUbMGai(o_XQjndIvM5d zC{keXupxs;f1DmO;W61==pQz?XPLd{cg4_K!rR?j2pbmn;|^#vDv1jZ?!OFmSuxle zMFO}tZ@%dDSPddxWWI3O6?fqo#kwE}YKi+yYJt&Ra12=VitnI$h{glb6hb8;6 zC@D4UKh&_&i(W3@sW!}9cEI}9Xm=d<)8!i`2MGIJX{)<&$!)tH)z9jw)E`UBf27WI zPn*74JzKFx0=$~a-enMLdhFC+ZKxw=zbj@M%!;Aad*+Dj%hD%xfDuYl@vD5Lfg2us zt3MMPr3G>=sEdud9FaRW#@z2A$YW`uYqpW{A_yV0nKYo}Xe~_?PcqZy7)`&5Gz&i9# zUm1DY0+Yx)VNA7p8%$!$gvibjINbDJ+RiLyEYGqul-6dZvr_67*s9I>I<#Mna^GYS zdRH&-(gk^M4{W}bL-~Bpb`qBa}(!KkTo#HhL1se3VAF_O6oKpU|5q^#8#^jPty?drbkaSEfr&r z?GgiI!U9R^5Pwp#cK$^fCdF&|MSHrcDT8L_9-F@tEYH&EayL=?G@U{-hGp!o9 zipyV|cJ5K_l3j=gBne$OO;o{ooAz+WW>yt9x7eOC%{shRZP+o~W28;h6815?K->6?_$mupcA+g6#Eykz zqX{}5sgs)6oL9*Xfs^;H6px@yJWsC{?!e?t2t8<Ow=Ag&cc^d|&(WFbONRn90D1fV{6SGfUyqgkD3l#Y?cu z0W&uR-A&Aq)(l{45G3gU7OVHBIERdGQ1>5;KOXV^e@2WJA zHU8$3V<;>gTkXFudX|DX$?ZV(+kwn1v|qE|&8{tna;tbcYyWquQBG9y z=E8%0PkjteF|-G3u7xN(2*Up6;-Q4o>%9ZbQ1yibktvCg6b2{d624jo<#zW}Kp1@+ z*9RAD-jTKvbGuhNl+I1<31W5Ng}D$|7wZ zixt!>2h9>vF&c{mN8%I4oVYLvVrW9WM1C>nn=jc==x}kF@=0M<4cde9zAmD7K|Orp zaqG~p2ls2?{)_8ky$<_qNTR~r5sRB1hPg;9C)v@w5UO#}6<7ckZ%M z1=6>=y0TJ88~CxTvmvbQq7jo-V7J5Vq3>hLD{E^y#Br=t*&tSQjP1iekwbZ7FM4#7dSUC;+^?qYj)52ay_tiG?^`s`SC-JI)4m~vqab1v`SW{p}f+Y2B z!`)gYosusv3<{9Rk*D!(m>+|fIjAfh{)q3z)m+vC2&vI?s~8NoJC9|3`FeB9Y^b`G zI}s%bIgm5N5}9y^Ky91Qt{-}!FX(iZG~uYt{uDP4j*b-v7on&dBgMo@fKlXL%3U+v zz+co}HeRswcPxOhAZn8$G8AQ93KZfIRn(0sQSW`^G_lONGdSq+*h^{m`dh14iNs_# zLFa1ePvx`BM_C3sflYJ!6+y|Ii}^#SlJQ+Vi}&h1QBY^0YAAgNKJXNlXCWfnkY9MH zJ-Y)n6p?OD%*iPJ8lgz6)Ta^z!gZFDm|+DDVPHFPfD_P`jMbG9k&QBdAi1gpLyv$Rt|CWK98d{ zw#(x!8qm|2;~SYTr+()uN41#sT9&FKjJn|Z@#LOa1t&aTzN=W*qjX4Ee6OBsSrke0 z2bnwlo$=Sid;BFL6XAtV*Sk>X?Knw2XKAMrvp|8dt>w%~Z*#h!uAbw#jx1(n8x0kD zb$YSbSLO#$pGgp^Jfu43W_A&ps)R!vrbzW~b>`x|ddaLu%CCF~0P^3vO8qD91g}{9#Nd;kyeT%0aIZD6U zOGstMT}kXSeA1~%Z#)JIk4Wj> zP}%3C7891t*CqCWZ}~BO7J)j`CO)3lW(@#|PLrjPy?G|s|2y@8~BV&vpoWR&<+Bs6t5 z*Vy@bDd3Bd6dR>9pZ6^UD)y>7tIIJK2jch+biWeo%g7BNA^b@HaCr_10=i;3x#XT`Q~Ylb%wy|CAo3jfTy+Avvz9&8b7gRAD zR^64d#cJ@OfzH4(l5PC{jW?Hjr`SRnVW)D7AlZx{Z1SI16L#Ac23ihi`Eq#!|DYpk z4I<+1Q5LMwQcZOFl6+fm2QU~*<-M!?A+Ok4BKfUo%I=kYd(5hCt?|?D`cs(g zqH>^&tU-EAL$5i=-Afu`|72*!j+kMlZb1vvXdFnwIRvzoj9HRgFL{84YHXCI3!P2zQ&dz9@+d0L`|x|~58{yi@O4eO z<1bmvB$$^aeEkz<1}o1OE`MJ9TkK)|i9HmSq(O}&&1~Vhq5SG1VAPfG`|jL#>^{sO zcV$P?f%F|hTxUE)k0k>UyYEg=NM0e4t?l%D;{ds%=Ge1EUM;1HbepqQ5<<5A!NH1OvZ*9Y7^eR8AGd zWF^3oKKbLvAn7(sXu*h z<<+PryeX9pe#_Zs7$JuP=qlH!C$Y(-4`$3MlRpsZ@W^WAdJ8)~g4ms;;MPgJz`|F+ zk>)0eym?1%l|59V$<-P=BrNft^RtH`ZY~Th0+g;VA(zDbzbn13Y${=Y?<6TaxO~!! zM~n|y@;1QHzFqsG{2g&^S<+s!&gTayl_`&(Nf+^&syc!{|3{BUbO=Oc3yp10=&C#^ zw@>SDM+vz|BiFhBx4BBGb`_fdf9&s?AqE~5q-+xgFoG_VwG_s*Ic$b6u_rm#kcj={ z9d4e`0=3A&_lUiIOJM=vC z<%5y#8gp-+&q6th6G=q+P-LW`Y=1`%TDV#1)8L2mgBH<`OGvt3hEkaA*~UW3Krz?m zqE_wZJ^?XN$UM=9`rZk4Si2+G%XWg)o{LYW9l69FPW;;KAr^;Wtvh;+JYEQgOQO;a zUE{VrWz@eVRL@Bg%~-7F{DWaL0I+x2rsp6TYCEq052;540Ljmh2LMnb*e|K_=cKsU zjoNn1cSkYf&9sJQUwVTeSZ&E~-cTcwum)sWXjr9bGv4y<$a^l)Fj{ImO;LKdY2wBw z8VH~$nSO^C4h@3&xhSoxzcYd(*3h38X;}8X#e7)#OUbT69wSW4Lb4KZWh_r zj^RrE!fH}nM{B07q0N1kpZB)}rnZ+TCf4QmDOwfZJ#=Kx9WTB6>RCoF%PTxd2tjEAD*!hm2KGWUgRoSr6^LMLf9vfIDmj>SDV@}pXN6epz0ubZdANsm=&W67l|q=nOvvy>yr8@dq*dT9xEjnInYm9fNd*y-$Ii zM&%R&r6JAfZq2l!p@rYBe-=+YxEu6|!{;;>%8I)%Sg)un z>WZg;dy772da5{(pHK2O>8F}?N3r_4Wln%5=kne;pzb8jS5;pkJoG0#C0`$AD>j6k zUa@WWVu>SC-C4m0l964&=~G_E{l3z7Lev6{2o#tqIe}iM9fmOy%PqNNQwzbON`B|M=lMNU3?nG)`kL9x3`vzs-_~fGi5?S8)#Cz7LLvv31!G2^I>%l z)IBK-Ax2p-tGTq1@UNhZbdYC-ZoO)WxG?p`A=-x6M>blh;M($G+~3gsq{PQZ@jR2lNZEzNkRxey3KVf#nSji6`k zZzXYIVx=Ff!%zofHFRvE^@@$F;{jYzGsBe`!rV2r2I_O!Y)M=I2q~8Xs67_dayB(_ zyZK8r26ZKV98=ZTA0&CpR_&MKXH^QeTSk%KJ?}K^gSfcfFqcE)!`hCXLl*tcq<<;9 zXn!2+EL&AOmDOEW=%OL*`4ak|Vs6Fdvkx=C1{c zSL)UYW8;nO+YL}3Y{Ge>z&5gn2Mw+>3k5BWT~rB(Ov{=*S^oI@>)s5aHL?C1#s;!& z%kg>D2F0Y-G12{x3R&X3AM-PFLD(`ZluYERc>Io`9}d64 z+L5kE(SH?9+(SD=-I2pGYi&rGO4~JKs=*vgo97$-62*;^-Z6b~kaea^40FGe+FQ8Z z-`1`!b@N-zc~Ts+!eEwL{U*#(Z3{IC;Pke5^$$a&a;p&b$(LP*mn>-m811ijTUA2tFSe$mC?Ua-k= zPvA-0@E4k{D27Ab7~FEoQvp$EC$Y$ z!RQBFw02ees*)uR_3yWs-ZW)JD7Y+ZjJV{lulN9#x7#IF&}QG(M0#P35gp&{6R&rks(=zg{seyHe~IU$Xsp12Rn}Lq$?%)?#Q}sgO04A}!K0 zQnREmJ(A`{($a7Mq~9ON2{jsy{U69Yps@^E8Lp>r%>*_ za1ToHE7Q1UvWIMShdV9p!0QGRsm2r{=}g5=Qf}q{OleVb zaO_-`vFCULBzi%pLnG{k(BlZ$j6ykL{I;JH-?Y@bVz43^?hX^;&9PH2n#Orz$I-tR zsSO@6h;ourUWi{(9)@H4FaQ8vbUOceY*0FVl45^z#DrvIA|7`)PeoRpA_eRqV>&yg zaEt1x1MVpYtq9A@e)r@B6cePwxu_%)hx*pjHf@(*eY!ECxre3Nkg|C%%{!MNDR`5H zME1~yCUUG2VbD(Maph_C6uI}gyo)u4%>O<@e^f;y)C0krX-dAABpNMT5cEKuq;Dcv z4BFsaE!KUVN!KYN4N=uKHKAp<|-N-v^Ag> z2^8t;Uo1N$7*FRYo^UWAn0PH%S>{^0dN?;!7ZMfgM7{%&U%VHWg`do7GuI^q=-)hOV* zkz)Ew(78gv+=FEioi<+anXymgh)pLh?ieveALn4Z49xhy)N!I52X=t=UboA6g=~^`=?|c_Q10j}u=2S@LSr#GfKT^zXRC zp)S%qo|K(4y9SZ|@5W_XE(rZs)V`S+eBA9sUw?o8zg3Nr8`W7Bk-@4Sg0t9@sGsYY zVMS#=X{T0|=aX@~~ha1XltK>S0{1YxS$??<|xJu5l3TTx~(?0I^PmUmXm3ozK zhPuP1M9QH-Eg>8fziX_}hrXmWiRU{8LBKh{lFZIzNKSF9+p+1h*u30W26vOI6FjhG z-OKqt?sgA@7?+2t=cw-HIFt^=f(=J`K!>%#$RqtSs|~&0OW3pjycZ`}lDO(8nVFc& zmvUgX%v84cx5URF855-aRCRbwN<9?sBvdt^nM*_b9}{ViZDZ2oRFw>9K}kbM$+pgE zBZUYeTYs;Mh~hD8`*Caty4*ix>78~)it0{I z@&|whtZc@@4mJ>l|018%dewm1Qay$9sys@5d*2zlY7hVcb`s9iszL?PlyO~F1?JKp zHMcHOd(Zvs^Q4TqP9uxhE5E4iDp@%sy`?oINF1TLk9=Gof&qFK-A@=*&Rh7i2v4}l zfF9xl-@roP>n=Ogp7f-*sJ;_+z6Wy4a`M*iRtCN@udhJvs+(#|s!7#Cu3b$w+S$r@ z-8|=(5MGN(I7ocoG87Ut9$1b9BG_g-1t@1kY97tEGK>J{?cBP_m;Z zMO-em7?LKeFDM-_dF3-qPpC$vI#@CrF+mp8!3FNI!_!OTi6O#vgy2sA$Z{)BBm7s6 zUwa9NaH%h9Fx4ut-noUr!G|EQ{h5h4YV?10hbj4`n+yltQG+VYFb|FR_7q7bZ}yk) z(Kn*^Yfv@kUf^GL*JrX?zVyfh2#R{GvH#f4eX1+^!pf&lg2pe%;(8U^tpm;< z)ZlB@*s&&8h4$yaS9~hK;?u4tJrr25AB&+8n3ND_d?*QUn~kg6s>GQ#_I!U}L$;O6 zqxQXEmg{0CN8KS!E!n?x1(oO`$8Kso6e>fLU}Nadcz)ucv=9YTe$H55;(YD&0w;Li zs;Ch#*@P!ZQWuaeGU|w5jq?gxCKOY`eaAo5oOmz%7q`TU2i`!>S7Z7*h&-w%T~#tf zN{W?<1?XL_uDi-cRewuyswQX2oRQ=KqHj5yo?`s>#tF$c@ui(|H16%m6{z5Hnz+xk zH4@bphuMv9g(;xIHdjA}^(p7T$++oNzwQ1s3U7*%y5-4auzfwGA0TFoX1N-!MaNnk zL$&t*r`+j_#y`@l@il&sWw?0e(Wj6@80foyb_i5KyH>hmx{9r7W`Fi71RP2N=kCWP zSo~K}tdc~qI^p!}Tp?q(7x}4w=_%BMT!#QRKltc!UvmRIdie>5Q zomjESkuvhdJ4sF~GOU(uX?(z>uFlcd6!np?U%Ck$cR{((OGfi>llV2_1hWMa@wI^2 zUw=0|X7yXg1>R{BnCTKX=7ycP{QFv!xxZ&jE5dTHTAr`5?bK@9CUW#?5}CT*Be6L1kXW>c4<^XH!&AkS51I_+1lX>{v}#gTKxOk@3du zpdfW8gtzpGup*>|R>7-2l~zvzXwJoAh^* zv4q4$kFGSfILq&2num*{5IeGcOD7{f-xuxx1joq_8{i-<=lQ-{)SBNYy=aG@EH?ob zWvcFBD8dR7lG`XKLqriZBj-CV*CkrPM@({$oN)+@`%+Cs>05H|k;V z000I{2S}-a1kOl60(g{w0zs^kCV|brszB&ZmiIQ5J#%CFBDplMkD1E4yC8nx2CvUGwWxGk#_E9-A?!xrINK#%-C0tN> zJ{ecFq?E}CZcuf2mTXUVZiUYv2^>Z-XXtk&i|t`tKH-*=(Ivy-O_L@mF__SmspF%H zqmgb*9<;UV=leFatH!8|#zHKd<1}I*@Gk6sVyQ12LtleQ`96jt@~Y~DgcV?5|00f<8$M3sj4<4!)jl>I=2ekD3c#z� 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 wV5iLei4_VNjl{ + 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");