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 00000000..d9d0efa8 Binary files /dev/null and b/project-access-audit-anomaly-monitor/demo.mp4 differ diff --git a/project-access-audit-anomaly-monitor/demo.svg b/project-access-audit-anomaly-monitor/demo.svg new file mode 100644 index 00000000..f4266221 --- /dev/null +++ b/project-access-audit-anomaly-monitor/demo.svg @@ -0,0 +1 @@ +Project 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");