Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions project-data-residency-transfer-guard/demo.js
Original file line number Diff line number Diff line change
@@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540">
<rect width="960" height="540" fill="#f6f0df"/>
<rect x="52" y="48" width="856" height="444" rx="10" fill="#fffaf0" stroke="#614a2f" stroke-width="3"/>
<text x="92" y="116" font-family="Georgia, serif" font-size="38" fill="#2f2418">Data Residency Transfer Guard</text>
<text x="94" y="160" font-family="Menlo, monospace" font-size="18" fill="#5c4630">Synthetic reviewer artifact for SCIBASE User &amp; Project Management</text>
<g font-family="Menlo, monospace" font-size="22">
<rect x="96" y="214" width="${approved * 112}" height="54" fill="#2f855a"/>
<text x="112" y="249" fill="#fff">approved ${approved}</text>
<rect x="96" y="290" width="${Math.max(1, review) * 112}" height="54" fill="#b7791f"/>
<text x="112" y="325" fill="#fff">review ${review}</text>
<rect x="96" y="366" width="${blocked * 112}" height="54" fill="#b83232"/>
<text x="112" y="401" fill="#fff">blocked ${blocked}</text>
</g>
<text x="94" y="462" font-family="Menlo, monospace" font-size="16" fill="#614a2f">Report digest: ${report.reportDigest.slice(0, 32)}...</text>
</svg>
`;
fs.writeFileSync(path.join(reportDir, "summary.svg"), svg);

console.log(JSON.stringify(report.summary, null, 2));
143 changes: 143 additions & 0 deletions project-data-residency-transfer-guard/index.js
Original file line number Diff line number Diff line change
@@ -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,
};
27 changes: 27 additions & 0 deletions project-data-residency-transfer-guard/readme.md
Original file line number Diff line number Diff line change
@@ -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.
73 changes: 73 additions & 0 deletions project-data-residency-transfer-guard/render-video.js
Original file line number Diff line number Diff line change
@@ -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);
Loading