From b1c796449085c7735e833e6bc1fdadcede1a87ed Mon Sep 17 00:00:00 2001 From: zergzorg Date: Sat, 23 May 2026 01:10:47 +0300 Subject: [PATCH] Add enterprise dashboard cohort privacy guard --- .../README.md | 36 ++ .../acceptance-notes.md | 35 ++ .../demo.js | 93 ++++ .../index.js | 372 +++++++++++++ .../package.json | 11 + .../reports/dashboard-privacy-packet.json | 520 ++++++++++++++++++ .../reports/dashboard-privacy-report.md | 51 ++ .../reports/demo.mp4 | Bin 0 -> 5341 bytes .../reports/summary.svg | 16 + .../requirements-map.md | 19 + .../sample-data.js | 154 ++++++ .../test.js | 70 +++ 12 files changed, 1377 insertions(+) create mode 100644 enterprise-dashboard-cohort-privacy-guard/README.md create mode 100644 enterprise-dashboard-cohort-privacy-guard/acceptance-notes.md create mode 100644 enterprise-dashboard-cohort-privacy-guard/demo.js create mode 100644 enterprise-dashboard-cohort-privacy-guard/index.js create mode 100644 enterprise-dashboard-cohort-privacy-guard/package.json create mode 100644 enterprise-dashboard-cohort-privacy-guard/reports/dashboard-privacy-packet.json create mode 100644 enterprise-dashboard-cohort-privacy-guard/reports/dashboard-privacy-report.md create mode 100644 enterprise-dashboard-cohort-privacy-guard/reports/demo.mp4 create mode 100644 enterprise-dashboard-cohort-privacy-guard/reports/summary.svg create mode 100644 enterprise-dashboard-cohort-privacy-guard/requirements-map.md create mode 100644 enterprise-dashboard-cohort-privacy-guard/sample-data.js create mode 100644 enterprise-dashboard-cohort-privacy-guard/test.js diff --git a/enterprise-dashboard-cohort-privacy-guard/README.md b/enterprise-dashboard-cohort-privacy-guard/README.md new file mode 100644 index 00000000..f36b2434 --- /dev/null +++ b/enterprise-dashboard-cohort-privacy-guard/README.md @@ -0,0 +1,36 @@ +# Enterprise Dashboard Cohort Privacy Guard + +This module adds a focused Enterprise Tooling slice for issue #19: privacy review for organization-wide admin dashboard metrics before they are shown or exported. + +It evaluates synthetic institutional dashboard widgets for: + +- small-cohort suppression with k-anonymity thresholds +- named contributor heatmap re-identification risk +- private or invitation-only project label masking +- cross-lab and department rollup minimums +- direct identifier blocking +- sensitive initiative tag masking +- raw CSV, webhook, and external BI export blocks when suppressed rows are present + +The module is dependency-free and uses only synthetic fixtures. It does not call live analytics systems, identity providers, export services, billing systems, or third-party APIs. + +## Run + +```bash +npm run check +npm test +npm run demo +``` + +`npm run demo` writes reviewer artifacts under `reports/`: + +- `dashboard-privacy-packet.json` +- `dashboard-privacy-report.md` +- `summary.svg` +- `demo.mp4` + +## Scope + +This is intentionally distinct from broad admin dashboards, export pipelines, webhook replay ledgers, identity provisioning drift, retention/legal hold, data residency, SLA, secret rotation, quotas, API change governance, connector certification, incident response, funder exports, model governance, initiative tags, policy exceptions, IRB consent, SCIM deprovisioning, repository deposit reconciliation, notification escalation, cost allocation, LMS passback, payload redaction, and vendor DPA review. + +It covers the privacy trust boundary for aggregated dashboard metrics: what can be safely displayed or exported to institutional admins without exposing small groups, private project activity, or direct identifiers. diff --git a/enterprise-dashboard-cohort-privacy-guard/acceptance-notes.md b/enterprise-dashboard-cohort-privacy-guard/acceptance-notes.md new file mode 100644 index 00000000..5085197a --- /dev/null +++ b/enterprise-dashboard-cohort-privacy-guard/acceptance-notes.md @@ -0,0 +1,35 @@ +# Acceptance Notes + +The reviewer can verify this bounty slice without any external account or service. + +## Expected Synthetic Result + +The demo fixture produces: + +- 4 dashboard widgets reviewed +- 8 dashboard rows reviewed +- 1 widget held for privacy review +- 3 widgets released only after masking or rollup +- 1 suppressed row +- 3 masked or rolled-up rows +- 1 raw export block + +The critical case is `heatmap-private-oncology`: it is a private, recent, named-contributor activity row with a cohort smaller than the policy threshold and direct identifiers. The guard suppresses it, blocks raw exports, and emits admin remediation actions. + +## Validation + +```bash +npm run check +npm test +npm run demo +``` + +Optional video metadata check: + +```bash +ffprobe -v error -select_streams v:0 -show_entries stream=codec_name,width,height,duration -show_entries format=size,duration -of default=noprint_wrappers=1 reports/demo.mp4 +``` + +## Boundaries + +This module does not implement a production dashboard UI, data warehouse, live webhook delivery, identity sync, access control, billing, or export adapter. It provides a deterministic privacy gate and reviewer packet for the enterprise dashboard trust boundary. diff --git a/enterprise-dashboard-cohort-privacy-guard/demo.js b/enterprise-dashboard-cohort-privacy-guard/demo.js new file mode 100644 index 00000000..d8997fc8 --- /dev/null +++ b/enterprise-dashboard-cohort-privacy-guard/demo.js @@ -0,0 +1,93 @@ +const fs = require("node:fs") +const path = require("node:path") +const { spawnSync } = require("node:child_process") +const { evaluateDashboardPrivacy } = require("./index") +const { dashboards, organization, privacyPolicy } = require("./sample-data") + +const reportsDir = path.join(__dirname, "reports") +fs.mkdirSync(reportsDir, { recursive: true }) + +const packet = evaluateDashboardPrivacy({ + asOf: "2026-05-23T00:00:00.000Z", + organization, + dashboards, + policy: privacyPolicy, +}) + +fs.writeFileSync( + path.join(reportsDir, "dashboard-privacy-packet.json"), + `${JSON.stringify(packet, null, 2)}\n`, +) + +const markdown = [ + "# Enterprise Dashboard Cohort Privacy Report", + "", + `Organization: ${packet.organization.name}`, + `Widgets reviewed: ${packet.summary.totalWidgets}`, + `Rows reviewed: ${packet.summary.totalRows}`, + `Hold for privacy review: ${packet.summary.hold_for_privacy_review}`, + `Masked ready: ${packet.summary.masked_ready}`, + `Suppressed rows: ${packet.summary.suppressedRows}`, + `Masked or rolled-up rows: ${packet.summary.maskedRows}`, + `Blocked raw exports: ${packet.summary.blockedRawExports}`, + `Audit digest: \`${packet.audit.digest}\``, + "", + "## Dashboard Decisions", + ...packet.dashboards.flatMap((dashboard) => [ + "", + `### ${dashboard.title}`, + `- Status: ${dashboard.status}`, + `- Rows reviewed: ${dashboard.reviewedRows}`, + `- Suppressed rows: ${dashboard.suppressedRows}`, + `- Masked rows: ${dashboard.maskedRows}`, + `- Findings: ${dashboard.findings.map((finding) => finding.code).join(", ") || "none"}`, + `- Reviewer note: ${dashboard.reviewerNotes[0]}`, + ]), + "", + "## Admin Actions", + ...packet.adminActions.map((action) => ( + `- ${action.dashboardId}/${action.rowId}: ${action.action} (${action.reasons.join(", ")})` + )), + "", +] + +fs.writeFileSync(path.join(reportsDir, "dashboard-privacy-report.md"), markdown.join("\n")) + +const svg = ` + + Enterprise Dashboard Cohort Privacy Guard + Small-cohort suppression and export masking for institutional analytics + + ${packet.summary.hold_for_privacy_review} + privacy holds + + ${packet.summary.masked_ready} + masked ready + + ${packet.summary.totalRows} + rows reviewed + Controls: k-anonymity, named-contributor suppression, private project masking, rollups, raw export blocks. + Digest ${packet.audit.digest.slice(0, 28)}... + +` + +fs.writeFileSync(path.join(reportsDir, "summary.svg"), svg) + +const ffmpeg = spawnSync("ffmpeg", [ + "-y", + "-f", + "lavfi", + "-i", + "color=c=0x0f172a:s=960x540:d=5:r=15", + "-vf", + "drawbox=x=48:y=168:w=250:h=152:color=0x991b1b@1:t=fill,drawbox=x=355:y=168:w=250:h=152:color=0x0369a1@1:t=fill,drawbox=x=662:y=168:w=250:h=152:color=0x166534@1:t=fill,drawbox=x=48:y=365:w=864:h=18:color=0x14b8a6@1:t=fill", + "-pix_fmt", + "yuv420p", + path.join(reportsDir, "demo.mp4"), +], { stdio: "ignore" }) + +if (ffmpeg.status !== 0) { + console.warn("ffmpeg video generation failed; JSON, Markdown, and SVG reports were still generated.") +} + +console.log(`Wrote enterprise dashboard privacy artifacts to ${reportsDir}`) diff --git a/enterprise-dashboard-cohort-privacy-guard/index.js b/enterprise-dashboard-cohort-privacy-guard/index.js new file mode 100644 index 00000000..d66aec69 --- /dev/null +++ b/enterprise-dashboard-cohort-privacy-guard/index.js @@ -0,0 +1,372 @@ +const crypto = require("node:crypto") + +const DEFAULT_POLICY = Object.freeze({ + minCohortSize: 7, + minNamedContributorCount: 5, + minLabsForCrossLabMetric: 3, + minDepartmentsForInstitutionRollup: 2, + maxRecentWindowHoursForSmallCohort: 24, + restrictedExportTargets: ["csv", "webhook", "external_bi"], + privateVisibilityValues: ["private", "invitation-only", "institutional-only"], +}) + +function evaluateDashboardPrivacy(input) { + const policy = { ...DEFAULT_POLICY, ...(input.policy || {}) } + const asOf = input.asOf || new Date().toISOString() + const dashboards = Array.isArray(input.dashboards) ? input.dashboards : [] + const organization = input.organization || { id: "unknown", name: "Unknown organization" } + + const dashboardDecisions = dashboards.map((dashboard) => evaluateDashboard(dashboard, policy)) + const adminActions = collectAdminActions(dashboardDecisions) + const exportManifest = buildExportManifest(dashboardDecisions, policy) + const summary = summarize(dashboardDecisions, adminActions, exportManifest) + const auditPayload = { + asOf, + organization, + summary, + dashboards: dashboardDecisions.map((dashboard) => ({ + id: dashboard.id, + status: dashboard.status, + reviewedRows: dashboard.reviewedRows, + suppressedRows: dashboard.suppressedRows, + maskedRows: dashboard.maskedRows, + findings: dashboard.findings.map((finding) => ({ + code: finding.code, + severity: finding.severity, + rowId: finding.rowId, + })), + })), + exportManifest, + } + + return { + asOf, + organization, + summary, + dashboards: dashboardDecisions, + adminActions, + exportManifest, + audit: { + algorithm: "sha256", + digest: digest(auditPayload), + reviewedAt: asOf, + policyVersion: policy.version || "dashboard-cohort-privacy-v1", + }, + } +} + +function evaluateDashboard(dashboard, policy) { + const rows = Array.isArray(dashboard.rows) ? dashboard.rows : [] + const rowDecisions = rows.map((row) => evaluateRow(dashboard, row, policy)) + const findings = rowDecisions.flatMap((rowDecision) => rowDecision.findings) + const suppressedRows = rowDecisions.filter((row) => row.action === "suppress").length + const maskedRows = rowDecisions.filter((row) => row.action === "mask" || row.action === "roll_up").length + const criticalFindings = findings.filter((finding) => finding.severity === "critical").length + const warningFindings = findings.filter((finding) => finding.severity === "warning").length + const exportBlocked = rowDecisions.some((row) => row.exportDecision === "block_raw_export") + + let status = "ready" + if (criticalFindings > 0 || exportBlocked) { + status = "hold_for_privacy_review" + } else if (maskedRows > 0 || warningFindings > 0) { + status = "masked_ready" + } + + return { + id: dashboard.id, + title: dashboard.title, + metricKind: dashboard.metricKind, + audience: dashboard.audience, + exportTargets: dashboard.exportTargets || [], + status, + reviewedRows: rowDecisions.length, + suppressedRows, + maskedRows, + findings, + rowDecisions, + reviewerNotes: buildReviewerNotes(dashboard, rowDecisions, policy), + } +} + +function evaluateRow(dashboard, row, policy) { + const findings = [] + const cohortSize = numberOrZero(row.cohortSize) + const contributorCount = numberOrZero(row.contributorCount) + const labCount = numberOrZero(row.labCount) + const departmentCount = numberOrZero(row.departmentCount) + const recentWindowHours = numberOrZero(row.recentWindowHours) + const visibility = String(row.projectVisibility || "public") + const privateProject = policy.privateVisibilityValues.includes(visibility) + const rowId = row.id || `${dashboard.id}:${row.label || "row"}` + + if (cohortSize > 0 && cohortSize < policy.minCohortSize) { + findings.push(finding({ + code: "small_cohort", + severity: "critical", + rowId, + message: `Cohort size ${cohortSize} is below the minimum ${policy.minCohortSize}.`, + remediation: "Suppress this row or roll it into a larger department-level aggregate.", + })) + } + + if (dashboard.metricKind === "named_contributor_heatmap" && contributorCount < policy.minNamedContributorCount) { + findings.push(finding({ + code: "named_contributor_reidentification", + severity: "critical", + rowId, + message: `Named contributor count ${contributorCount} is below the minimum ${policy.minNamedContributorCount}.`, + remediation: "Replace named cells with anonymous activity buckets before dashboard release.", + })) + } + + if (dashboard.metricKind === "cross_lab_collaboration" && labCount < policy.minLabsForCrossLabMetric) { + findings.push(finding({ + code: "cross_lab_rollup_needed", + severity: "warning", + rowId, + message: `Only ${labCount} lab(s) contribute to this collaboration metric.`, + remediation: "Roll the row up to a broader initiative or institution-level collaboration metric.", + })) + } + + if (dashboard.metricKind === "institution_rollup" && departmentCount > 0 && departmentCount < policy.minDepartmentsForInstitutionRollup) { + findings.push(finding({ + code: "department_rollup_too_narrow", + severity: "warning", + rowId, + message: `Only ${departmentCount} department(s) appear in an institution-wide rollup.`, + remediation: "Aggregate with another department or hide the department label.", + })) + } + + if (privateProject) { + findings.push(finding({ + code: "private_project_mask_required", + severity: "warning", + rowId, + message: `Visibility ${visibility} requires project and lab labels to be masked.`, + remediation: "Replace project names with privacy-safe aliases and omit direct project links.", + })) + } + + if (row.containsDirectIdentifiers === true) { + findings.push(finding({ + code: "direct_identifier_block", + severity: "critical", + rowId, + message: "The dashboard row includes direct identifiers.", + remediation: "Remove names, emails, handles, employee IDs, and direct project URLs before export.", + })) + } + + if (recentWindowHours > 0 && recentWindowHours <= policy.maxRecentWindowHoursForSmallCohort && cohortSize < policy.minCohortSize * 2) { + findings.push(finding({ + code: "recent_activity_window", + severity: "warning", + rowId, + message: `Recent ${recentWindowHours}h activity can reveal individual behavior in this cohort.`, + remediation: "Use a weekly or monthly window for small groups.", + })) + } + + if (Array.isArray(row.sensitiveTags) && row.sensitiveTags.length > 0) { + findings.push(finding({ + code: "sensitive_tag_exposure", + severity: "warning", + rowId, + message: `Sensitive tags present: ${row.sensitiveTags.join(", ")}.`, + remediation: "Display only approved initiative aliases in institutional dashboards.", + })) + } + + const critical = findings.some((item) => item.severity === "critical") + const warning = findings.some((item) => item.severity === "warning") + let action = "publish" + if (critical) { + action = "suppress" + } else if (findings.some((item) => item.code.includes("rollup") || item.code.includes("roll_up"))) { + action = "roll_up" + } else if (warning) { + action = "mask" + } + + const exportDecision = critical && intersects(dashboard.exportTargets, policy.restrictedExportTargets) + ? "block_raw_export" + : "allow_sanitized_export" + + return { + rowId, + label: row.label, + cohortSize, + contributorCount, + labCount, + departmentCount, + projectVisibility: visibility, + action, + exportDecision, + publicLabel: buildPublicLabel(row, action), + publicValue: action === "suppress" ? null : row.value, + findings, + } +} + +function buildPublicLabel(row, action) { + if (action === "suppress") { + return "Suppressed small cohort" + } + + if (action === "roll_up") { + return row.rollupLabel || "Institution aggregate" + } + + if (action === "mask") { + return row.maskedLabel || "Masked cohort" + } + + return row.label || "Published cohort" +} + +function buildReviewerNotes(dashboard, rowDecisions, policy) { + const notes = [] + const suppressed = rowDecisions.filter((row) => row.action === "suppress") + const masked = rowDecisions.filter((row) => row.action === "mask") + const rollups = rowDecisions.filter((row) => row.action === "roll_up") + + if (suppressed.length > 0) { + notes.push(`${suppressed.length} row(s) are held because they fall below k=${policy.minCohortSize} or expose direct identifiers.`) + } + + if (masked.length > 0) { + notes.push(`${masked.length} row(s) can be shown after project/lab labels and sensitive tags are masked.`) + } + + if (rollups.length > 0) { + notes.push(`${rollups.length} row(s) need broader aggregation before dashboard release.`) + } + + if (notes.length === 0) { + notes.push(`${dashboard.title} is ready for institutional dashboard release.`) + } + + return notes +} + +function collectAdminActions(dashboards) { + return dashboards.flatMap((dashboard) => { + return dashboard.rowDecisions + .filter((row) => row.action !== "publish" || row.exportDecision !== "allow_sanitized_export") + .map((row) => ({ + dashboardId: dashboard.id, + rowId: row.rowId, + action: actionToAdminTask(row.action, row.exportDecision), + severity: row.findings.some((finding) => finding.severity === "critical") ? "critical" : "warning", + reasons: row.findings.map((finding) => finding.code), + })) + }) +} + +function buildExportManifest(dashboards, policy) { + return dashboards.map((dashboard) => { + const blockedRows = dashboard.rowDecisions + .filter((row) => row.exportDecision === "block_raw_export") + .map((row) => row.rowId) + + const allowedTargets = (dashboard.exportTargets || []) + .filter((target) => blockedRows.length === 0 || !policy.restrictedExportTargets.includes(target)) + + return { + dashboardId: dashboard.id, + status: blockedRows.length > 0 ? "raw_export_blocked" : "sanitized_export_ready", + allowedTargets, + blockedRows, + sanitizedRows: dashboard.rowDecisions + .filter((row) => row.action !== "suppress") + .map((row) => ({ + rowId: row.rowId, + label: row.publicLabel, + value: row.publicValue, + action: row.action, + })), + } + }) +} + +function summarize(dashboards, adminActions, exportManifest) { + const totals = dashboards.reduce((summary, dashboard) => { + summary.totalWidgets += 1 + summary.totalRows += dashboard.reviewedRows + summary.suppressedRows += dashboard.suppressedRows + summary.maskedRows += dashboard.maskedRows + summary.criticalFindings += dashboard.findings.filter((finding) => finding.severity === "critical").length + summary.warningFindings += dashboard.findings.filter((finding) => finding.severity === "warning").length + summary[dashboard.status] = (summary[dashboard.status] || 0) + 1 + return summary + }, { + totalWidgets: 0, + totalRows: 0, + suppressedRows: 0, + maskedRows: 0, + criticalFindings: 0, + warningFindings: 0, + ready: 0, + masked_ready: 0, + hold_for_privacy_review: 0, + }) + + return { + ...totals, + adminActionCount: adminActions.length, + blockedRawExports: exportManifest.filter((item) => item.status === "raw_export_blocked").length, + } +} + +function actionToAdminTask(action, exportDecision) { + if (exportDecision === "block_raw_export") { + return "block_raw_export_and_rebuild_aggregate" + } + + if (action === "roll_up") { + return "roll_up_before_release" + } + + if (action === "mask") { + return "mask_labels_before_release" + } + + return "review_before_release" +} + +function finding({ code, severity, rowId, message, remediation }) { + return { code, severity, rowId, message, remediation } +} + +function intersects(left = [], right = []) { + return left.some((item) => right.includes(item)) +} + +function numberOrZero(value) { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : 0 +} + +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 digest(value) { + return crypto.createHash("sha256").update(stableStringify(value)).digest("hex") +} + +module.exports = { + DEFAULT_POLICY, + evaluateDashboardPrivacy, + stableStringify, +} diff --git a/enterprise-dashboard-cohort-privacy-guard/package.json b/enterprise-dashboard-cohort-privacy-guard/package.json new file mode 100644 index 00000000..2f02e85e --- /dev/null +++ b/enterprise-dashboard-cohort-privacy-guard/package.json @@ -0,0 +1,11 @@ +{ + "name": "enterprise-dashboard-cohort-privacy-guard", + "version": "1.0.0", + "private": true, + "type": "commonjs", + "scripts": { + "check": "node --check index.js && node --check sample-data.js && node --check test.js && node --check demo.js", + "test": "node test.js", + "demo": "node demo.js" + } +} diff --git a/enterprise-dashboard-cohort-privacy-guard/reports/dashboard-privacy-packet.json b/enterprise-dashboard-cohort-privacy-guard/reports/dashboard-privacy-packet.json new file mode 100644 index 00000000..01785a9c --- /dev/null +++ b/enterprise-dashboard-cohort-privacy-guard/reports/dashboard-privacy-packet.json @@ -0,0 +1,520 @@ +{ + "asOf": "2026-05-23T00:00:00.000Z", + "organization": { + "id": "northbridge-research", + "name": "Northbridge Research Institute", + "adminTenant": "enterprise-demo" + }, + "summary": { + "totalWidgets": 4, + "totalRows": 8, + "suppressedRows": 1, + "maskedRows": 3, + "criticalFindings": 3, + "warningFindings": 8, + "ready": 0, + "masked_ready": 3, + "hold_for_privacy_review": 1, + "adminActionCount": 4, + "blockedRawExports": 1 + }, + "dashboards": [ + { + "id": "contributor-heatmap", + "title": "Contributor Activity Heatmap", + "metricKind": "named_contributor_heatmap", + "audience": "department_admins", + "exportTargets": [ + "dashboard", + "csv", + "external_bi" + ], + "status": "hold_for_privacy_review", + "reviewedRows": 2, + "suppressedRows": 1, + "maskedRows": 0, + "findings": [ + { + "code": "small_cohort", + "severity": "critical", + "rowId": "heatmap-private-oncology", + "message": "Cohort size 4 is below the minimum 7.", + "remediation": "Suppress this row or roll it into a larger department-level aggregate." + }, + { + "code": "named_contributor_reidentification", + "severity": "critical", + "rowId": "heatmap-private-oncology", + "message": "Named contributor count 3 is below the minimum 5.", + "remediation": "Replace named cells with anonymous activity buckets before dashboard release." + }, + { + "code": "private_project_mask_required", + "severity": "warning", + "rowId": "heatmap-private-oncology", + "message": "Visibility private requires project and lab labels to be masked.", + "remediation": "Replace project names with privacy-safe aliases and omit direct project links." + }, + { + "code": "direct_identifier_block", + "severity": "critical", + "rowId": "heatmap-private-oncology", + "message": "The dashboard row includes direct identifiers.", + "remediation": "Remove names, emails, handles, employee IDs, and direct project URLs before export." + }, + { + "code": "recent_activity_window", + "severity": "warning", + "rowId": "heatmap-private-oncology", + "message": "Recent 12h activity can reveal individual behavior in this cohort.", + "remediation": "Use a weekly or monthly window for small groups." + }, + { + "code": "sensitive_tag_exposure", + "severity": "warning", + "rowId": "heatmap-private-oncology", + "message": "Sensitive tags present: DOCTORAL_WORK, HUMAN_SUBJECTS.", + "remediation": "Display only approved initiative aliases in institutional dashboards." + } + ], + "rowDecisions": [ + { + "rowId": "heatmap-public-ai-lab", + "label": "AI Methods Lab weekly activity", + "cohortSize": 18, + "contributorCount": 11, + "labCount": 1, + "departmentCount": 1, + "projectVisibility": "public", + "action": "publish", + "exportDecision": "allow_sanitized_export", + "publicLabel": "AI Methods Lab weekly activity", + "publicValue": 184, + "findings": [] + }, + { + "rowId": "heatmap-private-oncology", + "label": "Oncology pilot named contributors", + "cohortSize": 4, + "contributorCount": 3, + "labCount": 1, + "departmentCount": 1, + "projectVisibility": "private", + "action": "suppress", + "exportDecision": "block_raw_export", + "publicLabel": "Suppressed small cohort", + "publicValue": null, + "findings": [ + { + "code": "small_cohort", + "severity": "critical", + "rowId": "heatmap-private-oncology", + "message": "Cohort size 4 is below the minimum 7.", + "remediation": "Suppress this row or roll it into a larger department-level aggregate." + }, + { + "code": "named_contributor_reidentification", + "severity": "critical", + "rowId": "heatmap-private-oncology", + "message": "Named contributor count 3 is below the minimum 5.", + "remediation": "Replace named cells with anonymous activity buckets before dashboard release." + }, + { + "code": "private_project_mask_required", + "severity": "warning", + "rowId": "heatmap-private-oncology", + "message": "Visibility private requires project and lab labels to be masked.", + "remediation": "Replace project names with privacy-safe aliases and omit direct project links." + }, + { + "code": "direct_identifier_block", + "severity": "critical", + "rowId": "heatmap-private-oncology", + "message": "The dashboard row includes direct identifiers.", + "remediation": "Remove names, emails, handles, employee IDs, and direct project URLs before export." + }, + { + "code": "recent_activity_window", + "severity": "warning", + "rowId": "heatmap-private-oncology", + "message": "Recent 12h activity can reveal individual behavior in this cohort.", + "remediation": "Use a weekly or monthly window for small groups." + }, + { + "code": "sensitive_tag_exposure", + "severity": "warning", + "rowId": "heatmap-private-oncology", + "message": "Sensitive tags present: DOCTORAL_WORK, HUMAN_SUBJECTS.", + "remediation": "Display only approved initiative aliases in institutional dashboards." + } + ] + } + ], + "reviewerNotes": [ + "1 row(s) are held because they fall below k=7 or expose direct identifiers." + ] + }, + { + "id": "cross-lab-collaboration", + "title": "Cross-Lab Collaboration Index", + "metricKind": "cross_lab_collaboration", + "audience": "institution_admins", + "exportTargets": [ + "dashboard", + "webhook" + ], + "status": "masked_ready", + "reviewedRows": 2, + "suppressedRows": 0, + "maskedRows": 1, + "findings": [ + { + "code": "cross_lab_rollup_needed", + "severity": "warning", + "rowId": "collab-materials-bio", + "message": "Only 2 lab(s) contribute to this collaboration metric.", + "remediation": "Roll the row up to a broader initiative or institution-level collaboration metric." + }, + { + "code": "private_project_mask_required", + "severity": "warning", + "rowId": "collab-materials-bio", + "message": "Visibility institutional-only requires project and lab labels to be masked.", + "remediation": "Replace project names with privacy-safe aliases and omit direct project links." + } + ], + "rowDecisions": [ + { + "rowId": "collab-materials-bio", + "label": "Materials x Bioengineering collaboration", + "cohortSize": 13, + "contributorCount": 9, + "labCount": 2, + "departmentCount": 2, + "projectVisibility": "institutional-only", + "action": "roll_up", + "exportDecision": "allow_sanitized_export", + "publicLabel": "Institution collaboration aggregate", + "publicValue": 0.72, + "findings": [ + { + "code": "cross_lab_rollup_needed", + "severity": "warning", + "rowId": "collab-materials-bio", + "message": "Only 2 lab(s) contribute to this collaboration metric.", + "remediation": "Roll the row up to a broader initiative or institution-level collaboration metric." + }, + { + "code": "private_project_mask_required", + "severity": "warning", + "rowId": "collab-materials-bio", + "message": "Visibility institutional-only requires project and lab labels to be masked.", + "remediation": "Replace project names with privacy-safe aliases and omit direct project links." + } + ] + }, + { + "rowId": "collab-open-science", + "label": "Open Science network", + "cohortSize": 46, + "contributorCount": 28, + "labCount": 6, + "departmentCount": 4, + "projectVisibility": "public", + "action": "publish", + "exportDecision": "allow_sanitized_export", + "publicLabel": "Open Science network", + "publicValue": 0.88, + "findings": [] + } + ], + "reviewerNotes": [ + "1 row(s) need broader aggregation before dashboard release." + ] + }, + { + "id": "compliance-open-access", + "title": "Open Access Compliance", + "metricKind": "institution_rollup", + "audience": "research_office", + "exportTargets": [ + "dashboard", + "csv", + "webhook" + ], + "status": "masked_ready", + "reviewedRows": 2, + "suppressedRows": 0, + "maskedRows": 1, + "findings": [ + { + "code": "department_rollup_too_narrow", + "severity": "warning", + "rowId": "oa-small-department", + "message": "Only 1 department(s) appear in an institution-wide rollup.", + "remediation": "Aggregate with another department or hide the department label." + } + ], + "rowDecisions": [ + { + "rowId": "oa-institution-total", + "label": "Institution open access readiness", + "cohortSize": 91, + "contributorCount": 61, + "labCount": 13, + "departmentCount": 5, + "projectVisibility": "public", + "action": "publish", + "exportDecision": "allow_sanitized_export", + "publicLabel": "Institution open access readiness", + "publicValue": 0.91, + "findings": [] + }, + { + "rowId": "oa-small-department", + "label": "Small clinical translation unit", + "cohortSize": 9, + "contributorCount": 7, + "labCount": 2, + "departmentCount": 1, + "projectVisibility": "public", + "action": "roll_up", + "exportDecision": "allow_sanitized_export", + "publicLabel": "Institution aggregate", + "publicValue": 0.67, + "findings": [ + { + "code": "department_rollup_too_narrow", + "severity": "warning", + "rowId": "oa-small-department", + "message": "Only 1 department(s) appear in an institution-wide rollup.", + "remediation": "Aggregate with another department or hide the department label." + } + ] + } + ], + "reviewerNotes": [ + "1 row(s) need broader aggregation before dashboard release." + ] + }, + { + "id": "storage-compute-usage", + "title": "Storage and Compute Usage", + "metricKind": "resource_usage", + "audience": "enterprise_admins", + "exportTargets": [ + "dashboard", + "csv" + ], + "status": "masked_ready", + "reviewedRows": 2, + "suppressedRows": 0, + "maskedRows": 1, + "findings": [ + { + "code": "private_project_mask_required", + "severity": "warning", + "rowId": "usage-invitation-only", + "message": "Visibility invitation-only requires project and lab labels to be masked.", + "remediation": "Replace project names with privacy-safe aliases and omit direct project links." + }, + { + "code": "sensitive_tag_exposure", + "severity": "warning", + "rowId": "usage-invitation-only", + "message": "Sensitive tags present: GRANT_TRACKED.", + "remediation": "Display only approved initiative aliases in institutional dashboards." + } + ], + "rowDecisions": [ + { + "rowId": "usage-public-core", + "label": "Shared core facilities", + "cohortSize": 37, + "contributorCount": 20, + "labCount": 5, + "departmentCount": 3, + "projectVisibility": "public", + "action": "publish", + "exportDecision": "allow_sanitized_export", + "publicLabel": "Shared core facilities", + "publicValue": 1290, + "findings": [] + }, + { + "rowId": "usage-invitation-only", + "label": "Invitation-only translational storage", + "cohortSize": 15, + "contributorCount": 8, + "labCount": 2, + "departmentCount": 2, + "projectVisibility": "invitation-only", + "action": "mask", + "exportDecision": "allow_sanitized_export", + "publicLabel": "Restricted translational storage", + "publicValue": 480, + "findings": [ + { + "code": "private_project_mask_required", + "severity": "warning", + "rowId": "usage-invitation-only", + "message": "Visibility invitation-only requires project and lab labels to be masked.", + "remediation": "Replace project names with privacy-safe aliases and omit direct project links." + }, + { + "code": "sensitive_tag_exposure", + "severity": "warning", + "rowId": "usage-invitation-only", + "message": "Sensitive tags present: GRANT_TRACKED.", + "remediation": "Display only approved initiative aliases in institutional dashboards." + } + ] + } + ], + "reviewerNotes": [ + "1 row(s) can be shown after project/lab labels and sensitive tags are masked." + ] + } + ], + "adminActions": [ + { + "dashboardId": "contributor-heatmap", + "rowId": "heatmap-private-oncology", + "action": "block_raw_export_and_rebuild_aggregate", + "severity": "critical", + "reasons": [ + "small_cohort", + "named_contributor_reidentification", + "private_project_mask_required", + "direct_identifier_block", + "recent_activity_window", + "sensitive_tag_exposure" + ] + }, + { + "dashboardId": "cross-lab-collaboration", + "rowId": "collab-materials-bio", + "action": "roll_up_before_release", + "severity": "warning", + "reasons": [ + "cross_lab_rollup_needed", + "private_project_mask_required" + ] + }, + { + "dashboardId": "compliance-open-access", + "rowId": "oa-small-department", + "action": "roll_up_before_release", + "severity": "warning", + "reasons": [ + "department_rollup_too_narrow" + ] + }, + { + "dashboardId": "storage-compute-usage", + "rowId": "usage-invitation-only", + "action": "mask_labels_before_release", + "severity": "warning", + "reasons": [ + "private_project_mask_required", + "sensitive_tag_exposure" + ] + } + ], + "exportManifest": [ + { + "dashboardId": "contributor-heatmap", + "status": "raw_export_blocked", + "allowedTargets": [ + "dashboard" + ], + "blockedRows": [ + "heatmap-private-oncology" + ], + "sanitizedRows": [ + { + "rowId": "heatmap-public-ai-lab", + "label": "AI Methods Lab weekly activity", + "value": 184, + "action": "publish" + } + ] + }, + { + "dashboardId": "cross-lab-collaboration", + "status": "sanitized_export_ready", + "allowedTargets": [ + "dashboard", + "webhook" + ], + "blockedRows": [], + "sanitizedRows": [ + { + "rowId": "collab-materials-bio", + "label": "Institution collaboration aggregate", + "value": 0.72, + "action": "roll_up" + }, + { + "rowId": "collab-open-science", + "label": "Open Science network", + "value": 0.88, + "action": "publish" + } + ] + }, + { + "dashboardId": "compliance-open-access", + "status": "sanitized_export_ready", + "allowedTargets": [ + "dashboard", + "csv", + "webhook" + ], + "blockedRows": [], + "sanitizedRows": [ + { + "rowId": "oa-institution-total", + "label": "Institution open access readiness", + "value": 0.91, + "action": "publish" + }, + { + "rowId": "oa-small-department", + "label": "Institution aggregate", + "value": 0.67, + "action": "roll_up" + } + ] + }, + { + "dashboardId": "storage-compute-usage", + "status": "sanitized_export_ready", + "allowedTargets": [ + "dashboard", + "csv" + ], + "blockedRows": [], + "sanitizedRows": [ + { + "rowId": "usage-public-core", + "label": "Shared core facilities", + "value": 1290, + "action": "publish" + }, + { + "rowId": "usage-invitation-only", + "label": "Restricted translational storage", + "value": 480, + "action": "mask" + } + ] + } + ], + "audit": { + "algorithm": "sha256", + "digest": "a767494fae0607e1c2211dd5218888859ffd8eb7615b3a3bfc097c0c0797c3bb", + "reviewedAt": "2026-05-23T00:00:00.000Z", + "policyVersion": "dashboard-cohort-privacy-v1" + } +} diff --git a/enterprise-dashboard-cohort-privacy-guard/reports/dashboard-privacy-report.md b/enterprise-dashboard-cohort-privacy-guard/reports/dashboard-privacy-report.md new file mode 100644 index 00000000..24bf7b68 --- /dev/null +++ b/enterprise-dashboard-cohort-privacy-guard/reports/dashboard-privacy-report.md @@ -0,0 +1,51 @@ +# Enterprise Dashboard Cohort Privacy Report + +Organization: Northbridge Research Institute +Widgets reviewed: 4 +Rows reviewed: 8 +Hold for privacy review: 1 +Masked ready: 3 +Suppressed rows: 1 +Masked or rolled-up rows: 3 +Blocked raw exports: 1 +Audit digest: `a767494fae0607e1c2211dd5218888859ffd8eb7615b3a3bfc097c0c0797c3bb` + +## Dashboard Decisions + +### Contributor Activity Heatmap +- Status: hold_for_privacy_review +- Rows reviewed: 2 +- Suppressed rows: 1 +- Masked rows: 0 +- Findings: small_cohort, named_contributor_reidentification, private_project_mask_required, direct_identifier_block, recent_activity_window, sensitive_tag_exposure +- Reviewer note: 1 row(s) are held because they fall below k=7 or expose direct identifiers. + +### Cross-Lab Collaboration Index +- Status: masked_ready +- Rows reviewed: 2 +- Suppressed rows: 0 +- Masked rows: 1 +- Findings: cross_lab_rollup_needed, private_project_mask_required +- Reviewer note: 1 row(s) need broader aggregation before dashboard release. + +### Open Access Compliance +- Status: masked_ready +- Rows reviewed: 2 +- Suppressed rows: 0 +- Masked rows: 1 +- Findings: department_rollup_too_narrow +- Reviewer note: 1 row(s) need broader aggregation before dashboard release. + +### Storage and Compute Usage +- Status: masked_ready +- Rows reviewed: 2 +- Suppressed rows: 0 +- Masked rows: 1 +- Findings: private_project_mask_required, sensitive_tag_exposure +- Reviewer note: 1 row(s) can be shown after project/lab labels and sensitive tags are masked. + +## Admin Actions +- contributor-heatmap/heatmap-private-oncology: block_raw_export_and_rebuild_aggregate (small_cohort, named_contributor_reidentification, private_project_mask_required, direct_identifier_block, recent_activity_window, sensitive_tag_exposure) +- cross-lab-collaboration/collab-materials-bio: roll_up_before_release (cross_lab_rollup_needed, private_project_mask_required) +- compliance-open-access/oa-small-department: roll_up_before_release (department_rollup_too_narrow) +- storage-compute-usage/usage-invitation-only: mask_labels_before_release (private_project_mask_required, sensitive_tag_exposure) diff --git a/enterprise-dashboard-cohort-privacy-guard/reports/demo.mp4 b/enterprise-dashboard-cohort-privacy-guard/reports/demo.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..aece76cd85c58695ec80d8e2a5ffa3a8c3eb9359 GIT binary patch literal 5341 zcmeHLe^3)KT@ZyPH`GDqfw*QaiU=innr`94Qc&3jahFV1XzF1kSAzHj>OpvyrwenDnTsG&eoHrl!VP&9j^+&_1gu zm#1SCt4H+;pd(5uFZz_Z1Vg)M#!eA3=eB1MEawtLrov9mCFha^?W2W&!r4iG#$3`* zQ5l4nvwJu{p;Wnm%ONBs0H*Nmlvz7vCBX>32ruvFSSR*KfrV41edU~;$|e|(EP81t zm{No)bArGtz|HZ`VHp(|rqXLCVF@%_EBZLQjhanSgqv1Wr=(Qy64uZiR7y_K?N&I| zo@OIdj|>*zD2EV4v4Zx%$Qhcn5sJVwu$a&UN%&+9gyFrkiWkEBR8AIXP=a}vP$km= zCnI_#T7@wKIZ|bs_kjyglxeKvmT51iK(<^?DF8gr+F`StG)qf3442cz(+URRWljsM zhU3dUstaVI#QB`%q6AWXQUcQoE&#smwk*!;Q-+6MC%YT{f4XCfZ~G#*SqdFm#mHcJP=*``g!ZCWzUf(uf(;-kNs0@ zob?w7Swf#^^M>U2&K@ZL>{<_=8gmw%L97FB*oCUowbFyqwDzRZ$0 zdRh`!@%Q477w>6qpMK|^s5EKf+y6ZJx1{gZcGmV!+c~(KU3cMqKZqVQMGZti}i zt#OqS)BE#t_4&3dspP`X3uj&sXH>nnZp^8K_!%dQo1Q-WN&EaGQ?_pZ!|IHu29nBF zCXc_^aqZ@mGWN{7|30|n=G||O#MaH4oNF!;mQT6PZ#y>7y<<~eL4aQWL)oG`@RdB=&>|6d&v36R+Kg{tcs;MFP$r&p`d4Fx6 zuGWv88h5(4>EPdIe%113M_-w@$**47I&<6v`Gxo;3%}m$Df5_RH1mP~v!8v8U73!7 z_hpXOJpabvplefcp~^GQ?=+qZtn^H^7{9v~FB9es<2oWtx`$Wa~vWDfL>>`dq`R z>Ee=*%?_}Ez}_Dc*z+R>#)HX3Q)_#8V5sp%&IS~H3q;c#E&Cw=;rju=8d2g>P?_Xt zJ-H|`e37WgJ=(esU0ARC?hRiKYCL||@C~qi!^8HE0J}_sZ5uZdY+G6c*xTBQ+R}%G z-H|y0tR8LKt0TfzMu5ecoM>uOhKKc!0Bc0s92s_eFKF)h91_#*_{)tj#eiNbSF0Uw7gNop-_OL+1n&m5mCe$Gl{CyoZJ9GeoAwq<5!*##U zn-Pk-3D1NI5S6MG8YU!Yuo>O}_tEwUP9$7Q7d&5+HN#YnRTbcpIYEI(t=7N;_8ViY zIm+c_c^V{ymwhz$7eHq=TK=JCm*!ywS$BaDey)aiOpEaIHJ|pe0*7^?V&VPb1}34} zt2qr{b}FmOl392pVPj}mKDJ602%R5wT2WmB@W&KY32g}0`7M621H~=er#4~fg0S;} z#T_?OVgcx9+pM-ZR*Iwufp_7jwJy}YlY;OkBFX`+1tu0v3tmN&%7N~`l-oakP5*d& zhyccdrp6^v%_bD>>HFg2XSyETyN9jgC0dby;)URz08ct2%RdM$AxTlS9RRVWvlyXK zm`{Mf@Xp|7*qc4fLtW6r^RP=AO)zL^jE7jR`2zYxuIJ-Pe)K%x;}L#7#)glR;p{P* zhNh#PKjXZaazR#W$S}d@72otS=*yvJ7=1oae39^YGW6-tryBhK>lnuk_nHxb;)d@v z3Efr2sx;6NFQ-vpQA&6 + + Enterprise Dashboard Cohort Privacy Guard + Small-cohort suppression and export masking for institutional analytics + + 1 + privacy holds + + 3 + masked ready + + 8 + rows reviewed + Controls: k-anonymity, named-contributor suppression, private project masking, rollups, raw export blocks. + Digest a767494fae0607e1c2211dd52188... + diff --git a/enterprise-dashboard-cohort-privacy-guard/requirements-map.md b/enterprise-dashboard-cohort-privacy-guard/requirements-map.md new file mode 100644 index 00000000..99539feb --- /dev/null +++ b/enterprise-dashboard-cohort-privacy-guard/requirements-map.md @@ -0,0 +1,19 @@ +# Requirements Map + +Issue #19 asks for enterprise tooling across admin dashboards, analytics, compliance tracking, APIs, webhooks, and exports. This slice maps to the dashboard privacy layer that those capabilities need before institutional admins can safely view or export metrics. + +| Issue #19 area | Implementation | +| --- | --- | +| Organization-wide admin dashboards | Reviews dashboard widgets for contributor activity, collaboration, open-access compliance, and resource usage. | +| Contributor analytics and activity heatmaps | Blocks named heatmap rows when contributor counts are below policy thresholds. | +| Usage stats and productivity metrics | Masks private usage rows and emits sanitized export rows. | +| Compliance tracking | Preserves institution-level compliance rollups while masking narrow department rows. | +| Custom tags and internal initiatives | Detects sensitive tags and requires approved aliases before dashboard release. | +| Webhooks and export pipelines | Blocks raw CSV, webhook, and external BI export targets when suppressed rows exist. | +| Institutional oversight | Emits admin action queues, reviewer notes, and stable audit digests for governance review. | + +## Non-Overlap Notes + +Existing issue #19 submissions cover broad enterprise dashboards, export packages, compliance packets, webhook delivery and replay, identity drift, retention/legal hold, data residency, SLA, secret rotation, quotas, API change governance, connector certification, incident response, funder exports, AI model governance, dashboard attribution, initiative tags, policy exceptions, IRB consent, data export approvals, SCIM deprovisioning, repository deposit reconciliation, notification escalation, cost allocation, LMS passback, webhook payload redaction, and vendor DPA review. + +This module is narrower: it validates whether aggregate dashboard rows can be shown or exported without re-identifying researchers, projects, departments, labs, or small cohorts. diff --git a/enterprise-dashboard-cohort-privacy-guard/sample-data.js b/enterprise-dashboard-cohort-privacy-guard/sample-data.js new file mode 100644 index 00000000..8deb8a2c --- /dev/null +++ b/enterprise-dashboard-cohort-privacy-guard/sample-data.js @@ -0,0 +1,154 @@ +const organization = { + id: "northbridge-research", + name: "Northbridge Research Institute", + adminTenant: "enterprise-demo", +} + +const privacyPolicy = { + version: "dashboard-cohort-privacy-v1", + minCohortSize: 7, + minNamedContributorCount: 5, + minLabsForCrossLabMetric: 3, + minDepartmentsForInstitutionRollup: 2, + maxRecentWindowHoursForSmallCohort: 24, +} + +const dashboards = [ + { + id: "contributor-heatmap", + title: "Contributor Activity Heatmap", + metricKind: "named_contributor_heatmap", + audience: "department_admins", + exportTargets: ["dashboard", "csv", "external_bi"], + rows: [ + { + id: "heatmap-public-ai-lab", + label: "AI Methods Lab weekly activity", + value: 184, + cohortSize: 18, + contributorCount: 11, + labCount: 1, + departmentCount: 1, + recentWindowHours: 168, + projectVisibility: "public", + }, + { + id: "heatmap-private-oncology", + label: "Oncology pilot named contributors", + maskedLabel: "Private oncology cohort", + value: 19, + cohortSize: 4, + contributorCount: 3, + labCount: 1, + departmentCount: 1, + recentWindowHours: 12, + projectVisibility: "private", + containsDirectIdentifiers: true, + sensitiveTags: ["DOCTORAL_WORK", "HUMAN_SUBJECTS"], + }, + ], + }, + { + id: "cross-lab-collaboration", + title: "Cross-Lab Collaboration Index", + metricKind: "cross_lab_collaboration", + audience: "institution_admins", + exportTargets: ["dashboard", "webhook"], + rows: [ + { + id: "collab-materials-bio", + label: "Materials x Bioengineering collaboration", + rollupLabel: "Institution collaboration aggregate", + value: 0.72, + cohortSize: 13, + contributorCount: 9, + labCount: 2, + departmentCount: 2, + recentWindowHours: 48, + projectVisibility: "institutional-only", + }, + { + id: "collab-open-science", + label: "Open Science network", + value: 0.88, + cohortSize: 46, + contributorCount: 28, + labCount: 6, + departmentCount: 4, + recentWindowHours: 168, + projectVisibility: "public", + }, + ], + }, + { + id: "compliance-open-access", + title: "Open Access Compliance", + metricKind: "institution_rollup", + audience: "research_office", + exportTargets: ["dashboard", "csv", "webhook"], + rows: [ + { + id: "oa-institution-total", + label: "Institution open access readiness", + value: 0.91, + cohortSize: 91, + contributorCount: 61, + labCount: 13, + departmentCount: 5, + recentWindowHours: 720, + projectVisibility: "public", + }, + { + id: "oa-small-department", + label: "Small clinical translation unit", + maskedLabel: "Clinical translation aggregate", + value: 0.67, + cohortSize: 9, + contributorCount: 7, + labCount: 2, + departmentCount: 1, + recentWindowHours: 168, + projectVisibility: "public", + }, + ], + }, + { + id: "storage-compute-usage", + title: "Storage and Compute Usage", + metricKind: "resource_usage", + audience: "enterprise_admins", + exportTargets: ["dashboard", "csv"], + rows: [ + { + id: "usage-public-core", + label: "Shared core facilities", + value: 1290, + cohortSize: 37, + contributorCount: 20, + labCount: 5, + departmentCount: 3, + recentWindowHours: 168, + projectVisibility: "public", + }, + { + id: "usage-invitation-only", + label: "Invitation-only translational storage", + maskedLabel: "Restricted translational storage", + value: 480, + cohortSize: 15, + contributorCount: 8, + labCount: 2, + departmentCount: 2, + recentWindowHours: 24, + projectVisibility: "invitation-only", + sensitiveTags: ["GRANT_TRACKED"], + }, + ], + }, +] + +module.exports = { + organization, + privacyPolicy, + dashboards, +} diff --git a/enterprise-dashboard-cohort-privacy-guard/test.js b/enterprise-dashboard-cohort-privacy-guard/test.js new file mode 100644 index 00000000..1ff115eb --- /dev/null +++ b/enterprise-dashboard-cohort-privacy-guard/test.js @@ -0,0 +1,70 @@ +const assert = require("node:assert/strict") +const { evaluateDashboardPrivacy } = require("./index") +const { dashboards, organization, privacyPolicy } = require("./sample-data") + +const packet = evaluateDashboardPrivacy({ + asOf: "2026-05-23T00:00:00.000Z", + organization, + dashboards, + policy: privacyPolicy, +}) + +assert.equal(packet.summary.totalWidgets, 4) +assert.equal(packet.summary.totalRows, 8) +assert.equal(packet.summary.hold_for_privacy_review, 1) +assert.equal(packet.summary.masked_ready, 3) +assert.equal(packet.summary.ready, 0) +assert.equal(packet.summary.suppressedRows, 1) +assert.equal(packet.summary.maskedRows, 3) +assert.equal(packet.summary.blockedRawExports, 1) + +const heatmap = packet.dashboards.find((dashboard) => dashboard.id === "contributor-heatmap") +assert.equal(heatmap.status, "hold_for_privacy_review") + +const privateOncology = heatmap.rowDecisions.find((row) => row.rowId === "heatmap-private-oncology") +assert.equal(privateOncology.action, "suppress") +assert.equal(privateOncology.exportDecision, "block_raw_export") +assert.equal(privateOncology.publicValue, null) +assert(privateOncology.findings.some((finding) => finding.code === "small_cohort")) +assert(privateOncology.findings.some((finding) => finding.code === "direct_identifier_block")) +assert(privateOncology.findings.some((finding) => finding.code === "named_contributor_reidentification")) + +const collaboration = packet.dashboards.find((dashboard) => dashboard.id === "cross-lab-collaboration") +const materialsBio = collaboration.rowDecisions.find((row) => row.rowId === "collab-materials-bio") +assert.equal(materialsBio.action, "roll_up") +assert.equal(materialsBio.publicLabel, "Institution collaboration aggregate") +assert(materialsBio.findings.some((finding) => finding.code === "cross_lab_rollup_needed")) + +const compliance = packet.dashboards.find((dashboard) => dashboard.id === "compliance-open-access") +const institutionTotal = compliance.rowDecisions.find((row) => row.rowId === "oa-institution-total") +assert.equal(institutionTotal.action, "publish") +assert.equal(institutionTotal.exportDecision, "allow_sanitized_export") + +const rawExportManifest = packet.exportManifest.find((manifest) => manifest.dashboardId === "contributor-heatmap") +assert.equal(rawExportManifest.status, "raw_export_blocked") +assert.deepEqual(rawExportManifest.allowedTargets, ["dashboard"]) +assert.deepEqual(rawExportManifest.blockedRows, ["heatmap-private-oncology"]) + +const repeated = evaluateDashboardPrivacy({ + asOf: "2026-05-23T00:00:00.000Z", + organization, + dashboards, + policy: privacyPolicy, +}) +assert.equal(repeated.audit.digest, packet.audit.digest) + +const lenient = evaluateDashboardPrivacy({ + asOf: "2026-05-23T00:00:00.000Z", + organization, + dashboards: [dashboards[0]], + policy: { + ...privacyPolicy, + minCohortSize: 3, + minNamedContributorCount: 2, + }, +}) +const lenientPrivateRow = lenient.dashboards[0].rowDecisions.find((row) => row.rowId === "heatmap-private-oncology") +assert.equal(lenientPrivateRow.action, "suppress") +assert(lenientPrivateRow.findings.some((finding) => finding.code === "direct_identifier_block")) + +console.log("enterprise dashboard cohort privacy guard tests passed")