From 356cae4e8ba958795debba662417828482c87d82 Mon Sep 17 00:00:00 2001 From: Julian Ahlmark Date: Fri, 29 May 2026 13:16:20 +0300 Subject: [PATCH] Add project data residency transfer guard --- project-data-residency-transfer-guard/demo.js | 79 ++++++++++ .../index.js | 143 ++++++++++++++++++ .../readme.md | 27 ++++ .../render-video.js | 73 +++++++++ .../data-residency-transfer-report.json | 98 ++++++++++++ .../reports/data-residency-transfer-report.md | 60 ++++++++ .../reports/demo.mp4 | Bin 0 -> 7003 bytes .../reports/summary.svg | 15 ++ .../sample-data.js | 128 ++++++++++++++++ project-data-residency-transfer-guard/test.js | 39 +++++ 10 files changed, 662 insertions(+) create mode 100644 project-data-residency-transfer-guard/demo.js create mode 100644 project-data-residency-transfer-guard/index.js create mode 100644 project-data-residency-transfer-guard/readme.md create mode 100644 project-data-residency-transfer-guard/render-video.js create mode 100644 project-data-residency-transfer-guard/reports/data-residency-transfer-report.json create mode 100644 project-data-residency-transfer-guard/reports/data-residency-transfer-report.md create mode 100644 project-data-residency-transfer-guard/reports/demo.mp4 create mode 100644 project-data-residency-transfer-guard/reports/summary.svg create mode 100644 project-data-residency-transfer-guard/sample-data.js create mode 100644 project-data-residency-transfer-guard/test.js diff --git a/project-data-residency-transfer-guard/demo.js b/project-data-residency-transfer-guard/demo.js new file mode 100644 index 00000000..f50f8110 --- /dev/null +++ b/project-data-residency-transfer-guard/demo.js @@ -0,0 +1,79 @@ +const fs = require("fs"); +const path = require("path"); + +const { evaluateResidencyTransfers } = require("./index"); +const { scenarios } = require("./sample-data"); + +const reportDir = path.join(__dirname, "reports"); +fs.mkdirSync(reportDir, { recursive: true }); + +const report = evaluateResidencyTransfers(scenarios); +fs.writeFileSync( + path.join(reportDir, "data-residency-transfer-report.json"), + `${JSON.stringify(report, null, 2)}\n` +); + +const lines = [ + "# Project Data Residency Transfer Guard", + "", + `Generated: ${report.generatedAt}`, + `Report digest: \`${report.reportDigest}\``, + "", + "## Summary", + "", + `- Total scenarios: ${report.summary.total}`, + `- Approved: ${report.summary.approved}`, + `- Needs review: ${report.summary.needs_review}`, + `- Blocked: ${report.summary.blocked}`, + `- Findings: ${report.summary.findingCount}`, + "", + "## Decisions", + "", +]; + +for (const decision of report.decisions) { + lines.push(`### ${decision.id}`); + lines.push(""); + lines.push(`- Decision: **${decision.decision}**`); + lines.push(`- Action: \`${decision.action}\``); + lines.push(`- Object: \`${decision.targetObject}\``); + lines.push(`- Classification: \`${decision.classification}\``); + lines.push(`- Route: ${decision.homeRegion} -> ${decision.destinationRegion}`); + lines.push(`- Audit digest: \`${decision.auditDigest}\``); + lines.push("- Findings:"); + const findings = decision.findings.length + ? decision.findings + : [{ severity: "info", code: "none", message: "No blocking or review findings." }]; + for (const finding of findings) { + lines.push(` - ${finding.severity}: ${finding.code} - ${finding.message}`); + } + lines.push(""); +} +if (lines[lines.length - 1] === "") { + lines.pop(); +} + +fs.writeFileSync(path.join(reportDir, "data-residency-transfer-report.md"), `${lines.join("\n")}\n`); + +const blocked = report.summary.blocked; +const review = report.summary.needs_review; +const approved = report.summary.approved; +const svg = ` + + + Data Residency Transfer Guard + Synthetic reviewer artifact for SCIBASE User & Project Management + + + approved ${approved} + + review ${review} + + blocked ${blocked} + + Report digest: ${report.reportDigest.slice(0, 32)}... + +`; +fs.writeFileSync(path.join(reportDir, "summary.svg"), svg); + +console.log(JSON.stringify(report.summary, null, 2)); diff --git a/project-data-residency-transfer-guard/index.js b/project-data-residency-transfer-guard/index.js new file mode 100644 index 00000000..964e4de9 --- /dev/null +++ b/project-data-residency-transfer-guard/index.js @@ -0,0 +1,143 @@ +const crypto = require("crypto"); + +const SENSITIVE_CLASSIFICATIONS = new Set([ + "restricted-human-genomic", + "clinical-derived-sensitive", + "controlled-dataset", + "embargoed-artifact", +]); + +function isSensitive(classification) { + return SENSITIVE_CLASSIFICATIONS.has(String(classification || "").toLowerCase()); +} + +function isCrossRegion(scenario) { + return scenario.project.homeRegion !== scenario.request.destinationRegion; +} + +function stableDigest(value) { + return crypto.createHash("sha256").update(JSON.stringify(value)).digest("hex"); +} + +function daysUntil(dateText, now = new Date("2026-05-29T00:00:00Z")) { + if (!dateText) return 0; + const target = new Date(`${dateText}T00:00:00Z`); + if (Number.isNaN(target.getTime())) return 0; + return Math.ceil((target.getTime() - now.getTime()) / 86_400_000); +} + +function evaluateScenario(scenario) { + const findings = []; + const sensitive = isSensitive(scenario.request.targetClassification); + const crossRegion = isCrossRegion(scenario); + const externalInstitution = + scenario.project.homeInstitution !== scenario.request.actorInstitution; + + if (sensitive && crossRegion && scenario.evidence.dpaStatus !== "approved") { + findings.push({ + severity: "blocker", + code: "missing-cross-region-dpa", + message: "Sensitive project data cannot cross residency boundaries without an approved DPA.", + }); + } + + if (sensitive && scenario.evidence.duaStatus === "expired") { + findings.push({ + severity: "blocker", + code: "expired-data-use-agreement", + message: "The data-use agreement is expired for the requested transfer.", + }); + } + + const embargoDays = daysUntil(scenario.evidence.exportEmbargoUntil); + if (embargoDays > 0) { + findings.push({ + severity: "blocker", + code: "active-export-embargo", + message: `Export embargo remains active for ${embargoDays} day(s).`, + }); + } + + if (externalInstitution && !scenario.evidence.externalPartnerAllowed) { + findings.push({ + severity: "blocker", + code: "external-partner-not-allowed", + message: "Project policy does not allow this external partner transfer.", + }); + } + + if (!scenario.evidence.collaboratorAffiliationVerified) { + findings.push({ + severity: "review", + code: "unverified-affiliation", + message: "Collaborator affiliation must be verified before access is granted.", + }); + } + + if (sensitive && !scenario.evidence.dataStewardApproval) { + findings.push({ + severity: "review", + code: "missing-data-steward-approval", + message: "Sensitive object access requires data-steward approval evidence.", + }); + } + + if (scenario.project.visibility === "public" && !sensitive && findings.length === 0) { + findings.push({ + severity: "info", + code: "public-metadata-safe", + message: "Public non-sensitive metadata transfer is allowed with current evidence.", + }); + } + + const blockers = findings.filter((finding) => finding.severity === "blocker"); + const reviewItems = findings.filter((finding) => finding.severity === "review"); + const decision = blockers.length ? "blocked" : reviewItems.length ? "needs_review" : "approved"; + + return { + id: scenario.id, + projectId: scenario.project.id, + action: scenario.request.action, + targetObject: scenario.request.targetObject, + classification: scenario.request.targetClassification, + homeRegion: scenario.project.homeRegion, + destinationRegion: scenario.request.destinationRegion, + crossRegion, + externalInstitution, + decision, + findings, + auditDigest: stableDigest({ + scenario, + decision, + findingCodes: findings.map((finding) => finding.code), + }), + }; +} + +function evaluateResidencyTransfers(scenarios) { + const decisions = scenarios.map(evaluateScenario); + const summary = decisions.reduce( + (acc, decision) => { + acc.total += 1; + acc[decision.decision] += 1; + acc.findingCount += decision.findings.length; + return acc; + }, + { total: 0, approved: 0, needs_review: 0, blocked: 0, findingCount: 0 } + ); + + return { + generatedAt: "2026-05-29T00:00:00.000Z", + guard: "project-data-residency-transfer-guard", + summary, + decisions, + reportDigest: stableDigest({ summary, decisions }), + }; +} + +module.exports = { + evaluateResidencyTransfers, + evaluateScenario, + isSensitive, + isCrossRegion, +}; diff --git a/project-data-residency-transfer-guard/readme.md b/project-data-residency-transfer-guard/readme.md new file mode 100644 index 00000000..c62fa4d0 --- /dev/null +++ b/project-data-residency-transfer-guard/readme.md @@ -0,0 +1,27 @@ +# Project Data Residency Transfer Guard + +This is a focused User & Project Management slice for SCIBASE project spaces. It evaluates project access and sharing changes before data crosses institutional, jurisdictional, or residency boundaries. + +The guard uses synthetic reviewer scenarios only. It does not call identity providers, OAuth, SAML, ORCID, production access-control systems, external APIs, payment systems, live projects, credentials, tokens, or private user data. + +## What It Checks + +- Project visibility and home institution region. +- Requested destination region and external collaborator institution. +- Dataset/object classification, including restricted human genomic and clinical-derived data. +- DPA, DUA, IRB, export embargo, and data-steward approval evidence. +- Deterministic audit digests for reviewer handoff. + +## Run + +```bash +node project-data-residency-transfer-guard/test.js +node project-data-residency-transfer-guard/demo.js +node project-data-residency-transfer-guard/render-video.js +``` + +Generated reviewer artifacts are written to `project-data-residency-transfer-guard/reports/`. + +## Scope Boundary + +This slice is distinct from existing #11 work around RBAC/workspace ledgers, privacy review, member lifecycle/offboarding, institutional recertification, anonymous review, identity merge/export, data-room consent, profile sync, archive handoff, access-audit anomaly, role delegation, invitation/MFA, funding attribution, service-token governance, deletion/erasure, break-glass, visibility transition, provisioning baseline, object-permission inheritance, reputation anomaly, session step-up, and collaborator conflict-of-interest guards. diff --git a/project-data-residency-transfer-guard/render-video.js b/project-data-residency-transfer-guard/render-video.js new file mode 100644 index 00000000..9542c55e --- /dev/null +++ b/project-data-residency-transfer-guard/render-video.js @@ -0,0 +1,73 @@ +const { execFileSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +const { evaluateResidencyTransfers } = require("./index"); +const { scenarios } = require("./sample-data"); + +const reportDir = path.join(__dirname, "reports"); +const mp4Path = path.join(reportDir, "demo.mp4"); +const framePath = path.join(reportDir, "demo-frame.ppm"); +const report = evaluateResidencyTransfers(scenarios); + +function rgb(hex) { + return [ + Number.parseInt(hex.slice(0, 2), 16), + Number.parseInt(hex.slice(2, 4), 16), + Number.parseInt(hex.slice(4, 6), 16), + ]; +} + +function rect(buffer, width, x, y, w, h, color) { + const [r, g, b] = rgb(color); + for (let row = y; row < y + h; row += 1) { + for (let col = x; col < x + w; col += 1) { + const idx = (row * width + col) * 3; + buffer[idx] = r; + buffer[idx + 1] = g; + buffer[idx + 2] = b; + } + } +} + +const width = 960; +const height = 540; +const pixels = Buffer.alloc(width * height * 3, 0xf6); +for (let i = 0; i < pixels.length; i += 3) { + pixels[i] = 0xf6; + pixels[i + 1] = 0xf0; + pixels[i + 2] = 0xdf; +} +rect(pixels, width, 52, 48, 856, 444, "fffaf0"); +rect(pixels, width, 52, 48, 856, 4, "614a2f"); +rect(pixels, width, 52, 488, 856, 4, "614a2f"); +rect(pixels, width, 52, 48, 4, 444, "614a2f"); +rect(pixels, width, 904, 48, 4, 444, "614a2f"); +rect(pixels, width, 96, 160, Math.max(1, report.summary.approved) * 120, 58, "2f855a"); +rect(pixels, width, 96, 248, Math.max(1, report.summary.needs_review) * 120, 58, "b7791f"); +rect(pixels, width, 96, 336, Math.max(1, report.summary.blocked) * 120, 58, "b83232"); +rect(pixels, width, 96, 432, 720, 12, "614a2f"); +fs.writeFileSync(framePath, Buffer.concat([Buffer.from(`P6\n${width} ${height}\n255\n`), pixels])); + +execFileSync( + "ffmpeg", + [ + "-y", + "-loop", + "1", + "-framerate", + "24", + "-i", + framePath, + "-t", + "5", + "-vf", + "format=yuv420p", + "-movflags", + "+faststart", + mp4Path, + ], + { stdio: "inherit" } +); + +console.log(mp4Path); diff --git a/project-data-residency-transfer-guard/reports/data-residency-transfer-report.json b/project-data-residency-transfer-guard/reports/data-residency-transfer-report.json new file mode 100644 index 00000000..1e181a09 --- /dev/null +++ b/project-data-residency-transfer-guard/reports/data-residency-transfer-report.json @@ -0,0 +1,98 @@ +{ + "generatedAt": "2026-05-29T00:00:00.000Z", + "guard": "project-data-residency-transfer-guard", + "summary": { + "total": 4, + "approved": 2, + "needs_review": 0, + "blocked": 2, + "findingCount": 5 + }, + "decisions": [ + { + "id": "pub-seq-archive-eu-review", + "projectId": "project-atlas-42", + "action": "grant_dataset_read", + "targetObject": "dataset:human-genomes-v3", + "classification": "restricted-human-genomic", + "homeRegion": "EU", + "destinationRegion": "US", + "crossRegion": true, + "externalInstitution": true, + "decision": "blocked", + "findings": [ + { + "severity": "blocker", + "code": "missing-cross-region-dpa", + "message": "Sensitive project data cannot cross residency boundaries without an approved DPA." + }, + { + "severity": "blocker", + "code": "active-export-embargo", + "message": "Export embargo remains active for 33 day(s)." + }, + { + "severity": "review", + "code": "missing-data-steward-approval", + "message": "Sensitive object access requires data-steward approval evidence." + } + ], + "auditDigest": "5833dcf52dc90fd540b3b5805320322a1be5204e3081ef44bc4dda04539e542d" + }, + { + "id": "open-metadata-catalogue", + "projectId": "project-open-19", + "action": "publish_metadata_snapshot", + "targetObject": "metadata:materials-index", + "classification": "public-metadata", + "homeRegion": "US", + "destinationRegion": "US", + "crossRegion": false, + "externalInstitution": false, + "decision": "approved", + "findings": [ + { + "severity": "info", + "code": "public-metadata-safe", + "message": "Public non-sensitive metadata transfer is allowed with current evidence." + } + ], + "auditDigest": "671f131e434ab50a1548feae1bd9389942765501defd705319c546a7566c50a7" + }, + { + "id": "private-clinical-partner-sync", + "projectId": "project-clinical-7", + "action": "export_analysis_notebook", + "targetObject": "notebook:clinical-endpoint-model", + "classification": "clinical-derived-sensitive", + "homeRegion": "US", + "destinationRegion": "EU", + "crossRegion": true, + "externalInstitution": true, + "decision": "blocked", + "findings": [ + { + "severity": "blocker", + "code": "expired-data-use-agreement", + "message": "The data-use agreement is expired for the requested transfer." + } + ], + "auditDigest": "7edb86c12360ad8342e565634a529b704bd90a972effe71eeac92b02ddd6bbb7" + }, + { + "id": "institutional-only-student-handoff", + "projectId": "project-field-23", + "action": "grant_document_comment", + "targetObject": "document:field-protocol-draft", + "classification": "internal-collaboration", + "homeRegion": "EU", + "destinationRegion": "EU", + "crossRegion": false, + "externalInstitution": false, + "decision": "approved", + "findings": [], + "auditDigest": "b5064e73fafbfd1e2c9d756868baca9f8af162c5d9b98c44454382fd0ea9c184" + } + ], + "reportDigest": "df69e10d399a5cdd39225df47e8e67c725a226b0e1eaf6c9540afe12ce467573" +} diff --git a/project-data-residency-transfer-guard/reports/data-residency-transfer-report.md b/project-data-residency-transfer-guard/reports/data-residency-transfer-report.md new file mode 100644 index 00000000..70e9dde3 --- /dev/null +++ b/project-data-residency-transfer-guard/reports/data-residency-transfer-report.md @@ -0,0 +1,60 @@ +# Project Data Residency Transfer Guard + +Generated: 2026-05-29T00:00:00.000Z +Report digest: `df69e10d399a5cdd39225df47e8e67c725a226b0e1eaf6c9540afe12ce467573` + +## Summary + +- Total scenarios: 4 +- Approved: 2 +- Needs review: 0 +- Blocked: 2 +- Findings: 5 + +## Decisions + +### pub-seq-archive-eu-review + +- Decision: **blocked** +- Action: `grant_dataset_read` +- Object: `dataset:human-genomes-v3` +- Classification: `restricted-human-genomic` +- Route: EU -> US +- Audit digest: `5833dcf52dc90fd540b3b5805320322a1be5204e3081ef44bc4dda04539e542d` +- Findings: + - blocker: missing-cross-region-dpa - Sensitive project data cannot cross residency boundaries without an approved DPA. + - blocker: active-export-embargo - Export embargo remains active for 33 day(s). + - review: missing-data-steward-approval - Sensitive object access requires data-steward approval evidence. + +### open-metadata-catalogue + +- Decision: **approved** +- Action: `publish_metadata_snapshot` +- Object: `metadata:materials-index` +- Classification: `public-metadata` +- Route: US -> US +- Audit digest: `671f131e434ab50a1548feae1bd9389942765501defd705319c546a7566c50a7` +- Findings: + - info: public-metadata-safe - Public non-sensitive metadata transfer is allowed with current evidence. + +### private-clinical-partner-sync + +- Decision: **blocked** +- Action: `export_analysis_notebook` +- Object: `notebook:clinical-endpoint-model` +- Classification: `clinical-derived-sensitive` +- Route: US -> EU +- Audit digest: `7edb86c12360ad8342e565634a529b704bd90a972effe71eeac92b02ddd6bbb7` +- Findings: + - blocker: expired-data-use-agreement - The data-use agreement is expired for the requested transfer. + +### institutional-only-student-handoff + +- Decision: **approved** +- Action: `grant_document_comment` +- Object: `document:field-protocol-draft` +- Classification: `internal-collaboration` +- Route: EU -> EU +- Audit digest: `b5064e73fafbfd1e2c9d756868baca9f8af162c5d9b98c44454382fd0ea9c184` +- Findings: + - info: none - No blocking or review findings. diff --git a/project-data-residency-transfer-guard/reports/demo.mp4 b/project-data-residency-transfer-guard/reports/demo.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..f5061d3d01ce74e5ec65b2cfe0a78a8b3b81724e GIT binary patch literal 7003 zcmeHLeN+_J6@Sa}8KZz=#P~HLNj(b7%<>^(rm_-Fu~CzlhSnbJc6Mf#9hun~W(HVn zV(_CL6Jkv)T0<<`(-W&i+ft%p5)4>vjy0{;lW4$L59*24sD%0z5)W+e+gT#AEPv$W zwEbh>nVomvz4zVUyZ3$EH;WJ=oO-n)XtIP*1j04^tV|_KNlF%lQ0!qzmMalLqEzYP zpdM7rmk}Db0hthf?v?u*z#h`hzo$7I6@gI9bzNo3Kv<%e`7zCd6a&@(cW4=cGaRnD z9lV_E@0iMSx(3u_Uexq}DX@U;Mh3(Yi)s8K(9f}2thpA7qzF-Pc&s^DhS2{`_W7XB z0j&ZPhoJ%Jg zT#_~2A8<_L@p6aKU;726@ISqaf5Bkjj8XwsM zym0&fJ02-R=4)^p&U#h`IWDjUVsAq2sNi%4>K{wIzs9oZgA*zwVU4K59 zA8FKOY;X$4_&ei8h9I*!A+u5NkT@g8I#nK0@>oRzEm)anVD`St=YhI>AI4akx^AuWx*E$S!CQ6SuM@LC>ED4iJ&7Zx*)r?Jc4B$ z3`)-Vi|D|XrG)Oejvw-UNbg&trin-FEWjB!EBKESjR znkcX^m;nY!xK%$0D@crv2O_w2UKJTo0=`46P?>5wD@zKaLz{&d!G%z8g9%Vn8LZ<} z8Hv{*S`NEX4Ya_~Fj;nnV-)O$!|o6m4TA_O?{}<<7fM~a17xzoyX~d20#bviK-V(9 z8f??nY%)OF;hIKUvj~ml-8@^N(^(`Qrpn-$s=Vfc?ka2l+FdMHS(;Uygao%A9=in% z+L~!0iE``=O#l&Z=#_Q*NCCHMl4HAJf9WopW9PfPL#}KQoR* z4X50#%lbSh_i{zk8n>j*z41le@{0-UH_nW5&wsD3&wHDDWq;K(`#?YP;A__&@s@l- zFNyo-lS$WZ^dA3g;+1XJ>q@@!{jUgw9@0}URiEo>sX=oR)+CsBPx>hG!j0U7NsB)x zinemJMAWk|=6NQETDc zzA=w3jrl3v>HbS%SfNY? zAD4p0ntXTv?Dp6!+IIBxynoa7^Iya_zn8YWwuOGU(E84+ zJ%7(iS^GFLb-I54gN^G7^i?NLCJWQsE)@M@-dWqhO>5si+7#co?YS+DuHP1)q0BF= z$hPE7HI=-Xj}qcGlMi+z9IJEHL{B)-Ozd3NIC{@2XT%#RiA5i6*?i=-^GfYr-Hzup zVmd|cy|Xo@CFKD}8vbM&Muq%Qjt3fi&R_|K4%jSA* zQEZiO5Ts%J85^i1+ZxsvCZ=VCfZP!bi4DVGb_PS%7?67aQVao^4=Mn8tZ;lt$Sc8+ z*f2m|4GGyZ0;J)kcT8wVZ+bAK;iWet6y)n!BS0EndY6s>Ssn~&c5v1VduUG~4T~BS7YZO0vy+W;n=02IQB)kTr(nZb-S8i z+3q*oK!<{Cm>&wVK6M1hd@u_ucENCvhL_)XUxwNw0Wa5&02$twK?hz9Ocbox@V+!w zY + + + Data Residency Transfer Guard + Synthetic reviewer artifact for SCIBASE User & Project Management + + + approved 2 + + review 0 + + blocked 2 + + Report digest: df69e10d399a5cdd39225df47e8e67c7... + diff --git a/project-data-residency-transfer-guard/sample-data.js b/project-data-residency-transfer-guard/sample-data.js new file mode 100644 index 00000000..f0d25c38 --- /dev/null +++ b/project-data-residency-transfer-guard/sample-data.js @@ -0,0 +1,128 @@ +const scenarios = [ + { + id: "pub-seq-archive-eu-review", + project: { + id: "project-atlas-42", + title: "Pan-European rare-disease sequencing archive", + visibility: "institutional-only", + homeInstitution: "Karolinska Institute", + homeRegion: "EU", + dataSteward: "github:steward-eu", + }, + request: { + actor: "github:reviewer-us", + actorInstitution: "Northbridge Genomics", + actorRegion: "US", + action: "grant_dataset_read", + targetObject: "dataset:human-genomes-v3", + targetClassification: "restricted-human-genomic", + destinationRegion: "US", + purpose: "external reproducibility review", + }, + evidence: { + dpaStatus: "missing", + duaStatus: "approved", + irbStatus: "approved", + exportEmbargoUntil: "2026-07-01", + institutionalPolicy: "eu-sensitive-data-stays-eu", + collaboratorAffiliationVerified: true, + dataStewardApproval: false, + externalPartnerAllowed: true, + }, + }, + { + id: "open-metadata-catalogue", + project: { + id: "project-open-19", + title: "Open materials metadata catalogue", + visibility: "public", + homeInstitution: "LTC Lab", + homeRegion: "US", + dataSteward: "github:steward-us", + }, + request: { + actor: "github:catalog-curator", + actorInstitution: "LTC Lab", + actorRegion: "US", + action: "publish_metadata_snapshot", + targetObject: "metadata:materials-index", + targetClassification: "public-metadata", + destinationRegion: "US", + purpose: "public catalogue refresh", + }, + evidence: { + dpaStatus: "not-required", + duaStatus: "not-required", + irbStatus: "not-required", + exportEmbargoUntil: null, + institutionalPolicy: "public-metadata-ok", + collaboratorAffiliationVerified: true, + dataStewardApproval: true, + externalPartnerAllowed: true, + }, + }, + { + id: "private-clinical-partner-sync", + project: { + id: "project-clinical-7", + title: "Private clinical outcomes workspace", + visibility: "private", + homeInstitution: "UCSF", + homeRegion: "US", + dataSteward: "github:clinical-steward", + }, + request: { + actor: "github:partner-ops", + actorInstitution: "TrialOps GmbH", + actorRegion: "EU", + action: "export_analysis_notebook", + targetObject: "notebook:clinical-endpoint-model", + targetClassification: "clinical-derived-sensitive", + destinationRegion: "EU", + purpose: "contract research partner review", + }, + evidence: { + dpaStatus: "approved", + duaStatus: "expired", + irbStatus: "approved", + exportEmbargoUntil: null, + institutionalPolicy: "partner-transfer-requires-current-dua", + collaboratorAffiliationVerified: true, + dataStewardApproval: true, + externalPartnerAllowed: true, + }, + }, + { + id: "institutional-only-student-handoff", + project: { + id: "project-field-23", + title: "Institutional field study workspace", + visibility: "institutional-only", + homeInstitution: "University of Oslo", + homeRegion: "EU", + dataSteward: "github:field-steward", + }, + request: { + actor: "github:student-collab", + actorInstitution: "University of Oslo", + actorRegion: "EU", + action: "grant_document_comment", + targetObject: "document:field-protocol-draft", + targetClassification: "internal-collaboration", + destinationRegion: "EU", + purpose: "student collaborator handoff", + }, + evidence: { + dpaStatus: "not-required", + duaStatus: "not-required", + irbStatus: "approved", + exportEmbargoUntil: null, + institutionalPolicy: "same-institution-collaboration-ok", + collaboratorAffiliationVerified: true, + dataStewardApproval: true, + externalPartnerAllowed: false, + }, + }, +]; + +module.exports = { scenarios }; diff --git a/project-data-residency-transfer-guard/test.js b/project-data-residency-transfer-guard/test.js new file mode 100644 index 00000000..0559ed8f --- /dev/null +++ b/project-data-residency-transfer-guard/test.js @@ -0,0 +1,39 @@ +const assert = require("assert"); + +const { evaluateResidencyTransfers, evaluateScenario, isSensitive } = require("./index"); +const { scenarios } = require("./sample-data"); + +assert.strictEqual(isSensitive("restricted-human-genomic"), true); +assert.strictEqual(isSensitive("public-metadata"), false); + +const euExport = evaluateScenario(scenarios[0]); +assert.strictEqual(euExport.decision, "blocked"); +assert(euExport.findings.some((finding) => finding.code === "missing-cross-region-dpa")); +assert(euExport.findings.some((finding) => finding.code === "active-export-embargo")); +assert(euExport.findings.some((finding) => finding.code === "missing-data-steward-approval")); + +const publicMetadata = evaluateScenario(scenarios[1]); +assert.strictEqual(publicMetadata.decision, "approved"); +assert(publicMetadata.findings.some((finding) => finding.code === "public-metadata-safe")); + +const expiredDua = evaluateScenario(scenarios[2]); +assert.strictEqual(expiredDua.decision, "blocked"); +assert(expiredDua.findings.some((finding) => finding.code === "expired-data-use-agreement")); + +const sameInstitution = evaluateScenario(scenarios[3]); +assert.strictEqual(sameInstitution.decision, "approved"); +assert.strictEqual(sameInstitution.crossRegion, false); +assert.strictEqual(sameInstitution.externalInstitution, false); + +const report = evaluateResidencyTransfers(scenarios); +assert.deepStrictEqual(report.summary, { + total: 4, + approved: 2, + needs_review: 0, + blocked: 2, + findingCount: 5, +}); +assert.match(report.reportDigest, /^[0-9a-f]{64}$/); +assert(report.decisions.every((decision) => /^[0-9a-f]{64}$/.test(decision.auditDigest))); + +console.log("project-data-residency-transfer-guard tests passed");