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 @@
+
\ 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 [
+ ``
+ ].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");