From 5dbe73ab22e68fedc5f9c967bbe64b1ffad7e43b Mon Sep 17 00:00:00 2001 From: Kyle Tree Date: Wed, 20 May 2026 03:48:52 -0700 Subject: [PATCH] Add project access audit anomaly monitor --- README.md | 4 + .../README.md | 32 ++ .../audit-report.json | 244 +++++++++++ project-access-audit-anomaly-monitor/demo.js | 19 + project-access-audit-anomaly-monitor/demo.mp4 | Bin 0 -> 34028 bytes project-access-audit-anomaly-monitor/demo.svg | 1 + project-access-audit-anomaly-monitor/index.js | 398 ++++++++++++++++++ .../requirements-map.md | 21 + .../reviewer-packet.md | 24 ++ .../sample-data.js | 185 ++++++++ project-access-audit-anomaly-monitor/test.js | 63 +++ 11 files changed, 991 insertions(+) create mode 100644 project-access-audit-anomaly-monitor/README.md create mode 100644 project-access-audit-anomaly-monitor/audit-report.json create mode 100644 project-access-audit-anomaly-monitor/demo.js create mode 100644 project-access-audit-anomaly-monitor/demo.mp4 create mode 100644 project-access-audit-anomaly-monitor/demo.svg create mode 100644 project-access-audit-anomaly-monitor/index.js create mode 100644 project-access-audit-anomaly-monitor/requirements-map.md create mode 100644 project-access-audit-anomaly-monitor/reviewer-packet.md create mode 100644 project-access-audit-anomaly-monitor/sample-data.js create mode 100644 project-access-audit-anomaly-monitor/test.js diff --git a/README.md b/README.md index d338cf68..5ef0346c 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,6 @@ # deepevents.ai deepevents.ai main codebase + +## User & Project Management + +- `project-access-audit-anomaly-monitor/` adds a self-contained #11 slice for project audit-log anomaly detection, restricted-access export holds, and owner-review packets. diff --git a/project-access-audit-anomaly-monitor/README.md b/project-access-audit-anomaly-monitor/README.md new file mode 100644 index 00000000..207a5119 --- /dev/null +++ b/project-access-audit-anomaly-monitor/README.md @@ -0,0 +1,32 @@ +# Project Access Audit Anomaly Monitor + +This module is a focused User & Project Management slice for SCIBASE issue #11. It turns project audit logs into deterministic reviewer packets that flag suspicious access patterns before restricted datasets, notebooks, or exports leak outside the intended project boundary. + +## What It Adds + +- Audit-event scoring for restricted downloads, bulk downloads, data exports, role changes, external invites, API token creation, visibility changes, and object-level permission drift. +- Identity posture checks for MFA, SAML, ORCID, inactive users, and stale external collaborator access windows. +- Object-level permission checks for restricted or embargoed data, download role requirements, restricted licenses, and bulk thresholds. +- Burst detection for rapid role changes or sensitive-download clusters. +- Project-level reviewer packets with owner queues, export-hold decisions, severity counts, JSON output, Markdown output, and SVG preview. + +## Why This Is Distinct + +Existing #11 submissions cover broad RBAC/workspace ledgers, privacy access reviews, member lifecycle/offboarding, institutional recertification, anonymous review escrow, identity merge/export, data-room consent, researcher profile sync, and project archive handoff. This slice focuses specifically on the project audit log and detects anomalous access behavior after permissions exist. + +## Run + +```bash +node project-access-audit-anomaly-monitor/test.js +node project-access-audit-anomaly-monitor/demo.js +``` + +The demo writes: + +- `project-access-audit-anomaly-monitor/audit-report.json` +- `project-access-audit-anomaly-monitor/reviewer-packet.md` +- `project-access-audit-anomaly-monitor/demo.svg` + +## Decision Policy + +Critical events freeze access and open a security review. High events require owner approval before the next sensitive download. Medium events enter the admin queue, while low events are retained for the audit trail. diff --git a/project-access-audit-anomaly-monitor/audit-report.json b/project-access-audit-anomaly-monitor/audit-report.json new file mode 100644 index 00000000..a75ce6e5 --- /dev/null +++ b/project-access-audit-anomaly-monitor/audit-report.json @@ -0,0 +1,244 @@ +{ + "generatedAt": "2026-05-20T10:48:48.620Z", + "scoredEvents": [ + { + "eventId": "evt:001", + "projectId": "project:neuro-alpha", + "actorId": "user:ext-lee", + "targetUserId": null, + "objectId": "object:patient-counts", + "type": "restricted_download", + "timestamp": "2026-05-20T02:10:00Z", + "score": 100, + "severity": "critical", + "recommendedAction": "freeze_access_and_open_security_review", + "reasons": [ + "MFA not enabled", + "ORCID not linked for attribution-sensitive event", + "external collaborator access is expired", + "restricted object touched", + "role reviewer is below required admin", + "new-country access compared with user history", + "after-hours access" + ] + }, + { + "eventId": "evt:002", + "projectId": "project:neuro-alpha", + "actorId": "user:ext-lee", + "targetUserId": null, + "objectId": "object:patient-counts", + "type": "bulk_download", + "timestamp": "2026-05-20T02:24:00Z", + "score": 100, + "severity": "critical", + "recommendedAction": "freeze_access_and_open_security_review", + "reasons": [ + "MFA not enabled", + "external collaborator access is expired", + "restricted object touched", + "role reviewer is below required admin", + "bulk download count 42 exceeds threshold 10", + "after-hours access" + ] + }, + { + "eventId": "evt:005", + "projectId": "project:materials-sensor", + "actorId": "user:inactive-jo", + "targetUserId": null, + "objectId": "object:sensor-raw", + "type": "data_export", + "timestamp": "2026-05-20T03:30:00Z", + "score": 100, + "severity": "critical", + "recommendedAction": "freeze_access_and_open_security_review", + "reasons": [ + "inactive user generated access event", + "embargoed object touched", + "restricted-license object exported" + ] + }, + { + "eventId": "evt:006", + "projectId": "project:materials-sensor", + "actorId": "user:inactive-jo", + "targetUserId": null, + "objectId": "object:sensor-raw", + "type": "api_token_created", + "timestamp": "2026-05-20T04:00:00Z", + "score": 85, + "severity": "critical", + "recommendedAction": "freeze_access_and_open_security_review", + "reasons": [ + "inactive user generated access event", + "embargoed object touched", + "new-country access compared with user history" + ] + }, + { + "eventId": "evt:004", + "projectId": "project:neuro-alpha", + "actorId": "user:admin-ray", + "targetUserId": null, + "objectId": "object:patient-counts", + "type": "object_permission_drift", + "timestamp": "2026-05-20T03:05:00Z", + "score": 81, + "severity": "high", + "recommendedAction": "require_owner_approval_before_next_download", + "reasons": [ + "restricted object touched", + "object permission drift exposes extra roles: reviewer, contributor" + ] + }, + { + "eventId": "evt:003", + "projectId": "project:neuro-alpha", + "actorId": "user:admin-ray", + "targetUserId": "user:ext-lee", + "objectId": null, + "type": "role_change", + "timestamp": "2026-05-20T02:31:00Z", + "score": 64, + "severity": "medium", + "recommendedAction": "queue_admin_review", + "reasons": [ + "unknown object referenced by audit event", + "external collaborator gained contributor-or-higher role", + "role change lacks approval ticket" + ] + }, + { + "eventId": "evt:007", + "projectId": "project:neuro-alpha", + "actorId": "user:owner-amy", + "targetUserId": null, + "objectId": null, + "type": "project_visibility_change", + "timestamp": "2026-05-20T05:00:00Z", + "score": 30, + "severity": "low", + "recommendedAction": "retain_for_audit", + "reasons": [ + "unknown object referenced by audit event" + ] + } + ], + "projectPackets": [ + { + "projectId": "project:neuro-alpha", + "projectTitle": "Neuro Alpha Collaboration", + "institutionId": "inst:western", + "maxScore": 100, + "severityCounts": { + "critical": 2, + "high": 1, + "medium": 1, + "low": 1 + }, + "holdExport": true, + "ownerQueue": [ + { + "eventId": "evt:001", + "severity": "critical", + "action": "freeze_access_and_open_security_review", + "reasons": [ + "MFA not enabled", + "ORCID not linked for attribution-sensitive event", + "external collaborator access is expired", + "restricted object touched", + "role reviewer is below required admin", + "new-country access compared with user history", + "after-hours access" + ] + }, + { + "eventId": "evt:002", + "severity": "critical", + "action": "freeze_access_and_open_security_review", + "reasons": [ + "MFA not enabled", + "external collaborator access is expired", + "restricted object touched", + "role reviewer is below required admin", + "bulk download count 42 exceeds threshold 10", + "after-hours access" + ] + }, + { + "eventId": "evt:004", + "severity": "high", + "action": "require_owner_approval_before_next_download", + "reasons": [ + "restricted object touched", + "object permission drift exposes extra roles: reviewer, contributor" + ] + } + ], + "reviewerSummary": "Restricted access should be frozen until owner/admin review completes." + }, + { + "projectId": "project:materials-sensor", + "projectTitle": "Materials Sensor Working Group", + "institutionId": "inst:eastern", + "maxScore": 100, + "severityCounts": { + "critical": 2 + }, + "holdExport": true, + "ownerQueue": [ + { + "eventId": "evt:005", + "severity": "critical", + "action": "freeze_access_and_open_security_review", + "reasons": [ + "inactive user generated access event", + "embargoed object touched", + "restricted-license object exported" + ] + }, + { + "eventId": "evt:006", + "severity": "critical", + "action": "freeze_access_and_open_security_review", + "reasons": [ + "inactive user generated access event", + "embargoed object touched", + "new-country access compared with user history" + ] + } + ], + "reviewerSummary": "Restricted access should be frozen until owner/admin review completes." + } + ], + "blockedObjects": [ + { + "objectId": "object:patient-counts", + "eventId": "evt:001", + "action": "temporary_export_hold" + }, + { + "objectId": "object:patient-counts", + "eventId": "evt:002", + "action": "temporary_export_hold" + }, + { + "objectId": "object:sensor-raw", + "eventId": "evt:005", + "action": "temporary_export_hold" + }, + { + "objectId": "object:sensor-raw", + "eventId": "evt:006", + "action": "temporary_export_hold" + } + ], + "stats": { + "projectCount": 2, + "eventCount": 7, + "criticalCount": 4, + "highCount": 1, + "heldExportCount": 2 + } +} diff --git a/project-access-audit-anomaly-monitor/demo.js b/project-access-audit-anomaly-monitor/demo.js new file mode 100644 index 00000000..b314737a --- /dev/null +++ b/project-access-audit-anomaly-monitor/demo.js @@ -0,0 +1,19 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { + buildAuditAnomalyMonitor, + buildReviewerMarkdown, + renderAuditSvg +} = require("./index"); +const sampleData = require("./sample-data"); + +const report = buildAuditAnomalyMonitor(sampleData); +const outDir = __dirname; + +fs.writeFileSync(path.join(outDir, "audit-report.json"), `${JSON.stringify(report, null, 2)}\n`); +fs.writeFileSync(path.join(outDir, "reviewer-packet.md"), buildReviewerMarkdown(report)); +fs.writeFileSync(path.join(outDir, "demo.svg"), renderAuditSvg(report)); + +console.log(JSON.stringify(report, null, 2)); diff --git a/project-access-audit-anomaly-monitor/demo.mp4 b/project-access-audit-anomaly-monitor/demo.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..d9d0efa862f2ecd6f30c8d5001611b4f6885fcde GIT binary patch literal 34028 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 Access Audit Anomaly MonitorFlags stale external access, restricted downloads, role bursts, and object-level drift.Neuro Alpha Collaborationmax 100 | export hold yes | critical 2 | high 1Materials Sensor Working Groupmax 100 | export hold yes | critical 2 | high 0 \ No newline at end of file diff --git a/project-access-audit-anomaly-monitor/index.js b/project-access-audit-anomaly-monitor/index.js new file mode 100644 index 00000000..5704cd61 --- /dev/null +++ b/project-access-audit-anomaly-monitor/index.js @@ -0,0 +1,398 @@ +"use strict"; + +const ROLE_RANK = Object.freeze({ + viewer: 1, + reviewer: 2, + contributor: 3, + admin: 4, + owner: 5 +}); + +const EVENT_WEIGHTS = Object.freeze({ + restricted_download: 34, + bulk_download: 28, + role_change: 24, + external_invite: 18, + permission_override: 26, + project_visibility_change: 22, + api_token_created: 20, + data_export: 30, + failed_mfa: 16, + object_permission_drift: 27 +}); + +function assertArray(value, name) { + if (!Array.isArray(value)) throw new TypeError(`${name} must be an array`); +} + +function clamp(value, min = 0, max = 100) { + return Math.max(min, Math.min(max, value)); +} + +function round(value, digits = 2) { + const factor = 10 ** digits; + return Math.round(value * factor) / factor; +} + +function parseTime(value) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) throw new Error(`Invalid timestamp: ${value}`); + return date; +} + +function hoursBetween(left, right) { + return Math.abs(parseTime(right).getTime() - parseTime(left).getTime()) / 36e5; +} + +function normalizeRole(role) { + return String(role || "viewer").trim().toLowerCase(); +} + +function roleRank(role) { + return ROLE_RANK[normalizeRole(role)] || 0; +} + +function userById(users) { + return new Map(users.map((user) => [user.id, user])); +} + +function objectById(objects) { + return new Map(objects.map((object) => [object.id, object])); +} + +function projectById(projects) { + return new Map(projects.map((project) => [project.id, project])); +} + +function isExternal(user, project) { + if (!user || !project) return true; + return user.institutionId !== project.institutionId || user.type === "external"; +} + +function postureRisk(user, event) { + let risk = 0; + const reasons = []; + if (!user?.mfaEnabled) { + risk += 16; + reasons.push("MFA not enabled"); + } + if (user?.requiresSaml && !user?.samlLinked) { + risk += 18; + reasons.push("SAML link missing"); + } + if (event.requiresOrcid && !user?.orcidLinked) { + risk += 10; + reasons.push("ORCID not linked for attribution-sensitive event"); + } + if (user?.status === "inactive") { + risk += 35; + reasons.push("inactive user generated access event"); + } + return { risk, reasons }; +} + +function staleExternalRisk(user, project, event) { + if (!isExternal(user, project)) return { risk: 0, reasons: [] }; + const expiresAt = user?.externalAccessExpiresAt; + if (!expiresAt) { + return { risk: 18, reasons: ["external collaborator has no access expiry"] }; + } + if (parseTime(expiresAt).getTime() < parseTime(event.timestamp).getTime()) { + return { risk: 36, reasons: ["external collaborator access is expired"] }; + } + const hoursToExpiry = hoursBetween(event.timestamp, expiresAt); + if (hoursToExpiry <= 48) { + return { risk: 10, reasons: ["external collaborator access expires within 48 hours"] }; + } + return { risk: 0, reasons: [] }; +} + +function restrictedObjectRisk(object, event, role) { + if (!object) return { risk: 8, reasons: ["unknown object referenced by audit event"] }; + let risk = 0; + const reasons = []; + if (object.sensitivity === "restricted" || object.sensitivity === "embargoed") { + risk += 18; + reasons.push(`${object.sensitivity} object touched`); + } + if (object.downloadRequiresRole && roleRank(role) < roleRank(object.downloadRequiresRole)) { + risk += 24; + reasons.push(`role ${role} is below required ${object.downloadRequiresRole}`); + } + if (event.type === "data_export" && object.license === "restricted") { + risk += 20; + reasons.push("restricted-license object exported"); + } + if (event.type === "bulk_download" && Number(event.count || 0) > Number(object.bulkThreshold || 25)) { + risk += 16; + reasons.push(`bulk download count ${event.count} exceeds threshold ${object.bulkThreshold || 25}`); + } + return { risk, reasons }; +} + +function burstRisk(event, allEvents) { + const related = allEvents.filter( + (candidate) => + candidate.projectId === event.projectId && + candidate.actorId === event.actorId && + candidate.id !== event.id && + hoursBetween(candidate.timestamp, event.timestamp) <= 1 + ); + let risk = 0; + const reasons = []; + const roleChanges = related.filter((candidate) => candidate.type === "role_change").length; + const downloads = related.filter((candidate) => + ["restricted_download", "bulk_download", "data_export"].includes(candidate.type) + ).length; + if (roleChanges >= 2) { + risk += 22; + reasons.push(`${roleChanges + 1} role changes by the same actor within one hour`); + } + if (downloads >= 3) { + risk += 18; + reasons.push(`${downloads + 1} sensitive/download events by the same actor within one hour`); + } + return { risk, reasons }; +} + +function roleChangeRisk(event, users) { + if (event.type !== "role_change") return { risk: 0, reasons: [] }; + const beforeRank = roleRank(event.beforeRole); + const afterRank = roleRank(event.afterRole); + const target = users.get(event.targetUserId); + let risk = 0; + const reasons = []; + if (afterRank - beforeRank >= 2) { + risk += 20; + reasons.push(`role increased from ${event.beforeRole} to ${event.afterRole}`); + } + if (target?.type === "external" && afterRank >= ROLE_RANK.contributor) { + risk += 18; + reasons.push("external collaborator gained contributor-or-higher role"); + } + if (!event.approvalTicket) { + risk += 14; + reasons.push("role change lacks approval ticket"); + } + return { risk, reasons }; +} + +function driftRisk(event, object) { + if (event.type !== "object_permission_drift") return { risk: 0, reasons: [] }; + const baseline = new Set(object?.expectedRoles || []); + const observed = new Set(event.observedRoles || []); + const extraRoles = [...observed].filter((role) => !baseline.has(role)); + if (extraRoles.length === 0) return { risk: 0, reasons: [] }; + return { + risk: 24 + extraRoles.length * 6, + reasons: [`object permission drift exposes extra roles: ${extraRoles.join(", ")}`] + }; +} + +function scoreEvent(event, context) { + const users = context.users; + const projects = context.projects; + const objects = context.objects; + const user = users.get(event.actorId); + const project = projects.get(event.projectId); + const object = objects.get(event.objectId); + const role = event.actorRole || user?.projectRoles?.[event.projectId] || "viewer"; + + const reasons = []; + let score = EVENT_WEIGHTS[event.type] || 8; + const parts = [ + postureRisk(user, event), + staleExternalRisk(user, project, event), + restrictedObjectRisk(object, event, role), + burstRisk(event, context.events), + roleChangeRisk(event, users), + driftRisk(event, object) + ]; + + for (const part of parts) { + score += part.risk; + reasons.push(...part.reasons); + } + + if (event.ipReputation === "new_country") { + score += 12; + reasons.push("new-country access compared with user history"); + } + if (event.afterHours) { + score += 6; + reasons.push("after-hours access"); + } + + const severity = score >= 85 ? "critical" : score >= 65 ? "high" : score >= 40 ? "medium" : "low"; + const recommendedAction = + severity === "critical" + ? "freeze_access_and_open_security_review" + : severity === "high" + ? "require_owner_approval_before_next_download" + : severity === "medium" + ? "queue_admin_review" + : "retain_for_audit"; + + return { + eventId: event.id, + projectId: event.projectId, + actorId: event.actorId, + targetUserId: event.targetUserId || null, + objectId: event.objectId || null, + type: event.type, + timestamp: event.timestamp, + score: clamp(round(score)), + severity, + recommendedAction, + reasons: [...new Set(reasons)] + }; +} + +function groupByProject(scoredEvents) { + return scoredEvents.reduce((acc, event) => { + if (!acc.has(event.projectId)) acc.set(event.projectId, []); + acc.get(event.projectId).push(event); + return acc; + }, new Map()); +} + +function buildProjectPacket(project, events) { + const severityCounts = events.reduce((acc, event) => { + acc[event.severity] = (acc[event.severity] || 0) + 1; + return acc; + }, {}); + const maxScore = events.reduce((max, event) => Math.max(max, event.score), 0); + const holdExport = events.some((event) => event.recommendedAction === "freeze_access_and_open_security_review"); + const ownerQueue = events + .filter((event) => ["critical", "high"].includes(event.severity)) + .map((event) => ({ + eventId: event.eventId, + severity: event.severity, + action: event.recommendedAction, + reasons: event.reasons + })); + + return { + projectId: project.id, + projectTitle: project.title, + institutionId: project.institutionId, + maxScore, + severityCounts, + holdExport, + ownerQueue, + reviewerSummary: holdExport + ? "Restricted access should be frozen until owner/admin review completes." + : "No critical freeze condition detected; retain audit trail and review high/medium events." + }; +} + +function buildAuditAnomalyMonitor(input) { + const data = input || {}; + assertArray(data.projects, "projects"); + assertArray(data.users, "users"); + assertArray(data.objects, "objects"); + assertArray(data.events, "events"); + + const context = { + users: userById(data.users), + projects: projectById(data.projects), + objects: objectById(data.objects), + events: data.events + }; + + const scoredEvents = data.events + .map((event) => scoreEvent(event, context)) + .sort((a, b) => b.score - a.score || a.timestamp.localeCompare(b.timestamp)); + const byProject = groupByProject(scoredEvents); + const projectPackets = data.projects.map((project) => buildProjectPacket(project, byProject.get(project.id) || [])); + const blockedObjects = scoredEvents + .filter((event) => event.severity === "critical" && event.objectId) + .map((event) => ({ + objectId: event.objectId, + eventId: event.eventId, + action: "temporary_export_hold" + })); + + return { + generatedAt: new Date().toISOString(), + scoredEvents, + projectPackets, + blockedObjects, + stats: { + projectCount: data.projects.length, + eventCount: data.events.length, + criticalCount: scoredEvents.filter((event) => event.severity === "critical").length, + highCount: scoredEvents.filter((event) => event.severity === "high").length, + heldExportCount: projectPackets.filter((packet) => packet.holdExport).length + } + }; +} + +function buildReviewerMarkdown(report) { + const lines = ["# Project Access Audit Anomaly Packet", ""]; + lines.push(`Generated events: ${report.stats.eventCount}`); + lines.push(`Critical: ${report.stats.criticalCount}; High: ${report.stats.highCount}`); + lines.push(""); + for (const packet of report.projectPackets) { + lines.push(`## ${packet.projectTitle}`); + lines.push(`- Project: ${packet.projectId}`); + lines.push(`- Max score: ${packet.maxScore}`); + lines.push(`- Export hold: ${packet.holdExport ? "yes" : "no"}`); + lines.push(`- Summary: ${packet.reviewerSummary}`); + if (packet.ownerQueue.length) { + lines.push("- Owner queue:"); + for (const item of packet.ownerQueue) { + lines.push(` - ${item.eventId}: ${item.severity} -> ${item.action}; ${item.reasons.join("; ")}`); + } + } + lines.push(""); + } + return `${lines.join("\n")}\n`; +} + +function renderAuditSvg(report) { + const width = 920; + const rowHeight = 82; + const height = 120 + report.projectPackets.length * rowHeight; + const rows = report.projectPackets + .map((packet, index) => { + const y = 88 + index * rowHeight; + const color = packet.holdExport ? "#be123c" : packet.maxScore >= 65 ? "#b45309" : "#0f766e"; + const bar = Math.max(18, Math.round(packet.maxScore * 3.2)); + return [ + ``, + ``, + `${escapeXml(packet.projectTitle)}`, + `max ${packet.maxScore} | export hold ${packet.holdExport ? "yes" : "no"} | critical ${packet.severityCounts.critical || 0} | high ${packet.severityCounts.high || 0}`, + ``, + ``, + `` + ].join(""); + }) + .join(""); + return [ + ``, + ``, + `Project Access Audit Anomaly Monitor`, + `Flags stale external access, restricted downloads, role bursts, and object-level drift.`, + rows, + `` + ].join(""); +} + +function escapeXml(value) { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +module.exports = { + EVENT_WEIGHTS, + ROLE_RANK, + buildAuditAnomalyMonitor, + buildReviewerMarkdown, + renderAuditSvg, + scoreEvent +}; diff --git a/project-access-audit-anomaly-monitor/requirements-map.md b/project-access-audit-anomaly-monitor/requirements-map.md new file mode 100644 index 00000000..eb9c0f05 --- /dev/null +++ b/project-access-audit-anomaly-monitor/requirements-map.md @@ -0,0 +1,21 @@ +# Requirements Map + +## Issue #11: User & Project Management + +| Requirement | Coverage | +| --- | --- | +| Authentication and identity posture | `postureRisk` checks MFA, SAML linkage, ORCID linkage, and inactive-user access. | +| Project spaces and audit logs | Synthetic project audit events model data exports, downloads, role changes, visibility changes, API token creation, and object-level permission drift. | +| Permissions and access control | `restrictedObjectRisk`, `roleChangeRisk`, and `driftRisk` validate object-level access, role elevation, and permission drift. | +| External collaborator controls | `staleExternalRisk` flags expired or missing external collaborator access windows. | +| Fine-grained object-level control | Objects include sensitivity, license, download role requirements, bulk thresholds, and expected roles. | +| Project-level audit review | `buildProjectPacket` emits project-level severity counts, owner queues, export holds, and reviewer summaries. | +| Governance evidence | `buildReviewerMarkdown` and `audit-report.json` provide deterministic review packets for admins and project owners. | +| Tests and demo | `test.js` verifies critical restricted-download detection, drift handling, role-change evidence, project export holds, low-risk retention, and reviewer packet generation. `demo.js` emits JSON, Markdown, and SVG artifacts. | + +## Acceptance Notes + +- Synthetic data only; no external service calls or private data. +- No dependencies beyond the Node.js standard library. +- This is an audit-log anomaly monitor, not another broad RBAC implementation. +- Critical access events produce explicit freeze/review actions instead of silently adding a score. diff --git a/project-access-audit-anomaly-monitor/reviewer-packet.md b/project-access-audit-anomaly-monitor/reviewer-packet.md new file mode 100644 index 00000000..42b3144c --- /dev/null +++ b/project-access-audit-anomaly-monitor/reviewer-packet.md @@ -0,0 +1,24 @@ +# Project Access Audit Anomaly Packet + +Generated events: 7 +Critical: 4; High: 1 + +## Neuro Alpha Collaboration +- Project: project:neuro-alpha +- Max score: 100 +- Export hold: yes +- Summary: Restricted access should be frozen until owner/admin review completes. +- Owner queue: + - evt:001: critical -> freeze_access_and_open_security_review; MFA not enabled; ORCID not linked for attribution-sensitive event; external collaborator access is expired; restricted object touched; role reviewer is below required admin; new-country access compared with user history; after-hours access + - evt:002: critical -> freeze_access_and_open_security_review; MFA not enabled; external collaborator access is expired; restricted object touched; role reviewer is below required admin; bulk download count 42 exceeds threshold 10; after-hours access + - evt:004: high -> require_owner_approval_before_next_download; restricted object touched; object permission drift exposes extra roles: reviewer, contributor + +## Materials Sensor Working Group +- Project: project:materials-sensor +- Max score: 100 +- Export hold: yes +- Summary: Restricted access should be frozen until owner/admin review completes. +- Owner queue: + - evt:005: critical -> freeze_access_and_open_security_review; inactive user generated access event; embargoed object touched; restricted-license object exported + - evt:006: critical -> freeze_access_and_open_security_review; inactive user generated access event; embargoed object touched; new-country access compared with user history + diff --git a/project-access-audit-anomaly-monitor/sample-data.js b/project-access-audit-anomaly-monitor/sample-data.js new file mode 100644 index 00000000..2b98d3d7 --- /dev/null +++ b/project-access-audit-anomaly-monitor/sample-data.js @@ -0,0 +1,185 @@ +"use strict"; + +module.exports = { + projects: [ + { + id: "project:neuro-alpha", + title: "Neuro Alpha Collaboration", + institutionId: "inst:western", + visibility: "invitation-only" + }, + { + id: "project:materials-sensor", + title: "Materials Sensor Working Group", + institutionId: "inst:eastern", + visibility: "institutional-only" + } + ], + users: [ + { + id: "user:owner-amy", + name: "Amy Owner", + type: "internal", + institutionId: "inst:western", + mfaEnabled: true, + samlLinked: true, + requiresSaml: true, + orcidLinked: true, + status: "active", + projectRoles: { + "project:neuro-alpha": "owner" + } + }, + { + id: "user:ext-lee", + name: "Lee External", + type: "external", + institutionId: "inst:guest", + mfaEnabled: false, + samlLinked: false, + requiresSaml: false, + orcidLinked: false, + status: "active", + externalAccessExpiresAt: "2026-05-19T18:00:00Z", + projectRoles: { + "project:neuro-alpha": "reviewer" + } + }, + { + id: "user:admin-ray", + name: "Ray Admin", + type: "internal", + institutionId: "inst:western", + mfaEnabled: true, + samlLinked: true, + requiresSaml: true, + orcidLinked: true, + status: "active", + projectRoles: { + "project:neuro-alpha": "admin" + } + }, + { + id: "user:inactive-jo", + name: "Jo Inactive", + type: "external", + institutionId: "inst:eastern", + mfaEnabled: true, + samlLinked: false, + requiresSaml: false, + orcidLinked: true, + status: "inactive", + externalAccessExpiresAt: "2026-05-25T00:00:00Z", + projectRoles: { + "project:materials-sensor": "contributor" + } + } + ], + objects: [ + { + id: "object:patient-counts", + projectId: "project:neuro-alpha", + title: "Restricted patient-derived count matrix", + sensitivity: "restricted", + downloadRequiresRole: "admin", + license: "restricted", + bulkThreshold: 10, + expectedRoles: ["owner", "admin"] + }, + { + id: "object:review-notebook", + projectId: "project:neuro-alpha", + title: "Reviewer analysis notebook", + sensitivity: "private", + downloadRequiresRole: "reviewer", + license: "CC-BY-4.0", + bulkThreshold: 25, + expectedRoles: ["owner", "admin", "reviewer"] + }, + { + id: "object:sensor-raw", + projectId: "project:materials-sensor", + title: "Embargoed sensor raw traces", + sensitivity: "embargoed", + downloadRequiresRole: "contributor", + license: "restricted", + bulkThreshold: 12, + expectedRoles: ["owner", "admin", "contributor"] + } + ], + events: [ + { + id: "evt:001", + type: "restricted_download", + timestamp: "2026-05-20T02:10:00Z", + projectId: "project:neuro-alpha", + objectId: "object:patient-counts", + actorId: "user:ext-lee", + actorRole: "reviewer", + requiresOrcid: true, + ipReputation: "new_country", + afterHours: true + }, + { + id: "evt:002", + type: "bulk_download", + timestamp: "2026-05-20T02:24:00Z", + projectId: "project:neuro-alpha", + objectId: "object:patient-counts", + actorId: "user:ext-lee", + actorRole: "reviewer", + count: 42, + afterHours: true + }, + { + id: "evt:003", + type: "role_change", + timestamp: "2026-05-20T02:31:00Z", + projectId: "project:neuro-alpha", + actorId: "user:admin-ray", + targetUserId: "user:ext-lee", + beforeRole: "reviewer", + afterRole: "contributor", + approvalTicket: null + }, + { + id: "evt:004", + type: "object_permission_drift", + timestamp: "2026-05-20T03:05:00Z", + projectId: "project:neuro-alpha", + objectId: "object:patient-counts", + actorId: "user:admin-ray", + observedRoles: ["owner", "admin", "reviewer", "contributor"] + }, + { + id: "evt:005", + type: "data_export", + timestamp: "2026-05-20T03:30:00Z", + projectId: "project:materials-sensor", + objectId: "object:sensor-raw", + actorId: "user:inactive-jo", + actorRole: "contributor", + count: 6, + afterHours: false + }, + { + id: "evt:006", + type: "api_token_created", + timestamp: "2026-05-20T04:00:00Z", + projectId: "project:materials-sensor", + objectId: "object:sensor-raw", + actorId: "user:inactive-jo", + actorRole: "contributor", + ipReputation: "new_country" + }, + { + id: "evt:007", + type: "project_visibility_change", + timestamp: "2026-05-20T05:00:00Z", + projectId: "project:neuro-alpha", + actorId: "user:owner-amy", + beforeVisibility: "invitation-only", + afterVisibility: "institutional-only" + } + ] +}; diff --git a/project-access-audit-anomaly-monitor/test.js b/project-access-audit-anomaly-monitor/test.js new file mode 100644 index 00000000..3bbc1fce --- /dev/null +++ b/project-access-audit-anomaly-monitor/test.js @@ -0,0 +1,63 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const { + buildAuditAnomalyMonitor, + buildReviewerMarkdown, + scoreEvent +} = require("./index"); +const sampleData = require("./sample-data"); + +const report = buildAuditAnomalyMonitor(sampleData); + +assert.equal(report.stats.projectCount, 2); +assert.equal(report.stats.eventCount, 7); +assert.ok(report.stats.criticalCount >= 2); +assert.ok(report.stats.heldExportCount >= 1); + +const topEvent = report.scoredEvents[0]; +assert.equal(topEvent.eventId, "evt:001"); +assert.equal(topEvent.severity, "critical"); +assert.equal(topEvent.recommendedAction, "freeze_access_and_open_security_review"); +assert.ok(topEvent.reasons.includes("MFA not enabled")); +assert.ok(topEvent.reasons.includes("external collaborator access is expired")); +assert.ok(topEvent.reasons.includes("restricted object touched")); +assert.ok(topEvent.reasons.includes("role reviewer is below required admin")); + +const driftEvent = report.scoredEvents.find((event) => event.eventId === "evt:004"); +assert.ok(driftEvent.reasons.some((reason) => reason.includes("object permission drift"))); +assert.ok(["high", "critical"].includes(driftEvent.severity)); + +const roleChange = report.scoredEvents.find((event) => event.eventId === "evt:003"); +assert.ok(roleChange.reasons.includes("external collaborator gained contributor-or-higher role")); +assert.ok(roleChange.reasons.includes("role change lacks approval ticket")); + +const materialsPacket = report.projectPackets.find((packet) => packet.projectId === "project:materials-sensor"); +assert.equal(materialsPacket.holdExport, true); +assert.ok(materialsPacket.ownerQueue.length >= 1); + +const markdown = buildReviewerMarkdown(report); +assert.ok(markdown.includes("Project Access Audit Anomaly Packet")); +assert.ok(markdown.includes("Neuro Alpha Collaboration")); +assert.ok(markdown.includes("Materials Sensor Working Group")); + +const context = { + users: new Map(sampleData.users.map((user) => [user.id, user])), + projects: new Map(sampleData.projects.map((project) => [project.id, project])), + objects: new Map(sampleData.objects.map((object) => [object.id, object])), + events: sampleData.events +}; +const lowRisk = scoreEvent( + { + id: "evt:low", + type: "project_visibility_change", + timestamp: "2026-05-20T09:00:00Z", + projectId: "project:neuro-alpha", + actorId: "user:owner-amy" + }, + context +); +assert.equal(lowRisk.severity, "low"); +assert.equal(lowRisk.recommendedAction, "retain_for_audit"); + +console.log("project-access-audit-anomaly-monitor tests passed");