diff --git a/user-project-management/access-invitation-policy-auditor/README.md b/user-project-management/access-invitation-policy-auditor/README.md
new file mode 100644
index 00000000..f285e506
--- /dev/null
+++ b/user-project-management/access-invitation-policy-auditor/README.md
@@ -0,0 +1,25 @@
+# Access Invitation Policy Auditor
+
+This prototype audits invitation, role, identity, and object-level access controls for scientific project workspaces.
+
+It checks synthetic project access records for:
+
+- active invitations past expiry
+- role escalation without owner approval
+- restricted dataset download bypasses
+- institutional-only access from mismatched SAML domains
+- elevated roles without ORCID or institutional verification
+- anonymous access to private or invitation-only projects
+- invite, membership, and object-permission audit-log gaps
+- restricted datasets without explicit download-deny rules
+- stale external collaborators retaining elevated roles
+
+## Run
+
+```sh
+node user-project-management/access-invitation-policy-auditor/test.js
+node user-project-management/access-invitation-policy-auditor/demo.js
+node user-project-management/access-invitation-policy-auditor/make-demo-video.js
+```
+
+All fixtures are synthetic. The module performs no account, identity-provider, private project, credential, SAML, ORCID, or external API access.
diff --git a/user-project-management/access-invitation-policy-auditor/demo.js b/user-project-management/access-invitation-policy-auditor/demo.js
new file mode 100644
index 00000000..019d74d9
--- /dev/null
+++ b/user-project-management/access-invitation-policy-auditor/demo.js
@@ -0,0 +1,60 @@
+"use strict";
+
+const fs = require("fs");
+const path = require("path");
+const { auditAccessPolicy, renderMarkdownReport } = require("./index");
+const { projectAccessPacket } = require("./sample-data");
+
+function escapeXml(value) {
+ return String(value)
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+}
+
+function renderSvg(report) {
+ const maxWidth = 720;
+ const riskWidth = Math.round((report.summary.riskScore / 100) * maxWidth);
+ const criticalWidth = Math.round((report.summary.criticalFindings / report.summary.findings) * maxWidth);
+ const highWidth = Math.round((report.summary.highFindings / report.summary.findings) * maxWidth);
+
+ return `
+`;
+}
+
+function main() {
+ const report = auditAccessPolicy(projectAccessPacket);
+ const reportsDir = path.join(__dirname, "reports");
+ fs.mkdirSync(reportsDir, { recursive: true });
+ fs.writeFileSync(path.join(reportsDir, "access-invitation-policy-audit.json"), JSON.stringify(report, null, 2));
+ fs.writeFileSync(path.join(reportsDir, "access-invitation-policy-audit.md"), renderMarkdownReport(report));
+ fs.writeFileSync(path.join(reportsDir, "access-invitation-policy-summary.svg"), renderSvg(report));
+ console.log(`decision=${report.summary.decision} riskScore=${report.summary.riskScore} findings=${report.summary.findings}`);
+ console.log(`reports=${reportsDir}`);
+}
+
+if (require.main === module) {
+ main();
+}
+
+module.exports = { renderSvg };
diff --git a/user-project-management/access-invitation-policy-auditor/index.js b/user-project-management/access-invitation-policy-auditor/index.js
new file mode 100644
index 00000000..7d701305
--- /dev/null
+++ b/user-project-management/access-invitation-policy-auditor/index.js
@@ -0,0 +1,267 @@
+"use strict";
+
+const crypto = require("crypto");
+
+const ROLE_RANK = {
+ viewer: 1,
+ reviewer: 2,
+ contributor: 3,
+ admin: 4,
+ owner: 5,
+};
+
+const WEIGHTS = {
+ critical: 26,
+ high: 18,
+ medium: 10,
+ low: 5,
+};
+
+function addFinding(findings, finding) {
+ findings.push({
+ severity: finding.severity || "medium",
+ recommendation: recommendationFor(finding.code),
+ ...finding,
+ });
+}
+
+function recommendationFor(code) {
+ const recommendations = {
+ EXPIRED_INVITE_ACTIVE: "Disable the invitation and rotate the access token.",
+ ROLE_ESCALATION_WITHOUT_APPROVAL: "Require owner approval before increasing collaborator role scope.",
+ OBJECT_PERMISSION_BYPASS: "Apply object-level deny rules before project-level role grants.",
+ INSTITUTION_SCOPE_MISMATCH: "Restrict institutional-only access to verified SAML domains.",
+ UNVERIFIED_IDENTITY_PRIVILEGE: "Require ORCID or institutional verification before granting elevated roles.",
+ ANONYMOUS_PRIVATE_ACCESS: "Block anonymous sessions from private or invitation-only workspaces.",
+ AUDIT_LOG_GAP: "Write an immutable audit-log event for every invite, role, and object-permission change.",
+ DATA_DOWNLOAD_NOT_RESTRICTED: "Set explicit deny-download policy for restricted datasets.",
+ STALE_EXTERNAL_COLLABORATOR: "Expire external collaborator access after inactivity or invite window close.",
+ };
+ return recommendations[code] || "Route this item for project-access review.";
+}
+
+function auditAccessPolicy(project, options = {}) {
+ const now = new Date(options.now || project.auditTime || new Date().toISOString());
+ const findings = [];
+ const auditEventKeys = new Set((project.auditLog || []).map((event) => event.key));
+ const identities = new Map((project.identities || []).map((identity) => [identity.userId, identity]));
+ const objectRules = project.objectPermissions || [];
+
+ for (const invite of project.invitations || []) {
+ if (invite.status === "active" && new Date(invite.expiresAt).getTime() < now.getTime()) {
+ addFinding(findings, {
+ code: "EXPIRED_INVITE_ACTIVE",
+ severity: "critical",
+ inviteId: invite.id,
+ userId: invite.userId,
+ message: `Invitation ${invite.id} is active after expiry ${invite.expiresAt}.`,
+ evidence: invite,
+ });
+ }
+
+ if (invite.role && invite.requestedRole && roleRank(invite.role) > roleRank(invite.requestedRole) && !invite.ownerApprovalId) {
+ addFinding(findings, {
+ code: "ROLE_ESCALATION_WITHOUT_APPROVAL",
+ severity: "high",
+ inviteId: invite.id,
+ userId: invite.userId,
+ message: `Invitation ${invite.id} grants ${invite.role} after ${invite.requestedRole} request without owner approval.`,
+ evidence: invite,
+ });
+ }
+
+ const identity = identities.get(invite.userId);
+ if (["admin", "owner", "contributor"].includes(invite.role) && (!identity || (!identity.orcidVerified && !identity.samlVerified))) {
+ addFinding(findings, {
+ code: "UNVERIFIED_IDENTITY_PRIVILEGE",
+ severity: "high",
+ inviteId: invite.id,
+ userId: invite.userId,
+ message: `${invite.userId} has elevated role ${invite.role} without verified ORCID or SAML identity.`,
+ evidence: { invite, identity },
+ });
+ }
+
+ if (!auditEventKeys.has(`invite:${invite.id}`)) {
+ addFinding(findings, {
+ code: "AUDIT_LOG_GAP",
+ severity: "medium",
+ inviteId: invite.id,
+ userId: invite.userId,
+ message: `Invitation ${invite.id} has no immutable audit-log event.`,
+ evidence: invite,
+ });
+ }
+ }
+
+ for (const membership of project.memberships || []) {
+ const identity = identities.get(membership.userId);
+ if (project.visibility === "institutional-only" && (!identity?.samlVerified || identity.institutionDomain !== project.institutionDomain)) {
+ addFinding(findings, {
+ code: "INSTITUTION_SCOPE_MISMATCH",
+ severity: "critical",
+ userId: membership.userId,
+ message: `${membership.userId} has institutional-only access without matching verified SAML domain.`,
+ evidence: { membership, identity, institutionDomain: project.institutionDomain },
+ });
+ }
+
+ if (membership.anonymous && ["private", "invitation-only", "institutional-only"].includes(project.visibility)) {
+ addFinding(findings, {
+ code: "ANONYMOUS_PRIVATE_ACCESS",
+ severity: "critical",
+ userId: membership.userId,
+ message: `Anonymous membership ${membership.userId} can access ${project.visibility} project.`,
+ evidence: membership,
+ });
+ }
+
+ const inactiveDays = daysBetween(membership.lastActiveAt, now.toISOString());
+ if (membership.external && inactiveDays > (project.policy?.externalInactivityLimitDays || 30) && membership.role !== "viewer") {
+ addFinding(findings, {
+ code: "STALE_EXTERNAL_COLLABORATOR",
+ severity: "medium",
+ userId: membership.userId,
+ message: `External collaborator ${membership.userId} has ${inactiveDays} inactive days with ${membership.role} role.`,
+ evidence: membership,
+ });
+ }
+
+ if (!auditEventKeys.has(`member:${membership.userId}:${membership.role}`)) {
+ addFinding(findings, {
+ code: "AUDIT_LOG_GAP",
+ severity: "medium",
+ userId: membership.userId,
+ message: `Membership ${membership.userId}/${membership.role} has no role audit-log event.`,
+ evidence: membership,
+ });
+ }
+ }
+
+ for (const rule of objectRules) {
+ const membership = (project.memberships || []).find((entry) => entry.userId === rule.userId);
+ if (!membership) continue;
+
+ if (rule.objectType === "dataset" && rule.action === "download" && rule.effect === "allow" && project.restrictedDatasets?.includes(rule.objectId)) {
+ addFinding(findings, {
+ code: "OBJECT_PERMISSION_BYPASS",
+ severity: "critical",
+ userId: rule.userId,
+ objectId: rule.objectId,
+ message: `${rule.userId} can download restricted dataset ${rule.objectId}.`,
+ evidence: { rule, membership },
+ });
+ }
+
+ if (rule.objectType === "dataset" && project.restrictedDatasets?.includes(rule.objectId)) {
+ const hasExplicitDeny = objectRules.some(
+ (candidate) =>
+ candidate.objectId === rule.objectId &&
+ candidate.userId === rule.userId &&
+ candidate.action === "download" &&
+ candidate.effect === "deny"
+ );
+ if (!hasExplicitDeny) {
+ addFinding(findings, {
+ code: "DATA_DOWNLOAD_NOT_RESTRICTED",
+ severity: "high",
+ userId: rule.userId,
+ objectId: rule.objectId,
+ message: `${rule.objectId} lacks explicit download deny for ${rule.userId}.`,
+ evidence: rule,
+ });
+ }
+ }
+
+ if (!auditEventKeys.has(`object:${rule.id}`)) {
+ addFinding(findings, {
+ code: "AUDIT_LOG_GAP",
+ severity: "low",
+ userId: rule.userId,
+ objectId: rule.objectId,
+ message: `Object permission ${rule.id} has no audit-log event.`,
+ evidence: rule,
+ });
+ }
+ }
+
+ const riskScore = Math.min(100, findings.reduce((score, finding) => score + WEIGHTS[finding.severity], 0));
+ const decision = riskScore >= 70 ? "hold-project-access" : riskScore >= 35 ? "repair-access-policy" : "access-policy-ready";
+ const auditDigest = crypto
+ .createHash("sha256")
+ .update(JSON.stringify(findings.map((finding) => [finding.code, finding.inviteId, finding.userId, finding.objectId]).sort()))
+ .digest("hex")
+ .slice(0, 16);
+
+ return {
+ project: {
+ id: project.id,
+ title: project.title,
+ visibility: project.visibility,
+ },
+ summary: {
+ decision,
+ riskScore,
+ auditDigest,
+ invitationsReviewed: (project.invitations || []).length,
+ membershipsReviewed: (project.memberships || []).length,
+ objectPermissionsReviewed: objectRules.length,
+ findings: findings.length,
+ criticalFindings: findings.filter((finding) => finding.severity === "critical").length,
+ highFindings: findings.filter((finding) => finding.severity === "high").length,
+ },
+ findings,
+ accessGates: [
+ { gate: "Invitations expire and cannot silently escalate roles.", passed: !findings.some((finding) => ["EXPIRED_INVITE_ACTIVE", "ROLE_ESCALATION_WITHOUT_APPROVAL"].includes(finding.code)) },
+ { gate: "Institutional and private workspaces reject anonymous or mismatched identities.", passed: !findings.some((finding) => ["INSTITUTION_SCOPE_MISMATCH", "ANONYMOUS_PRIVATE_ACCESS"].includes(finding.code)) },
+ { gate: "Restricted datasets cannot be downloaded by object-permission bypass.", passed: !findings.some((finding) => ["OBJECT_PERMISSION_BYPASS", "DATA_DOWNLOAD_NOT_RESTRICTED"].includes(finding.code)) },
+ { gate: "Elevated roles require verified identity.", passed: !findings.some((finding) => finding.code === "UNVERIFIED_IDENTITY_PRIVILEGE") },
+ { gate: "Invite, membership, and object-permission changes are audit logged.", passed: !findings.some((finding) => finding.code === "AUDIT_LOG_GAP") },
+ ],
+ };
+}
+
+function roleRank(role) {
+ return ROLE_RANK[String(role || "").toLowerCase()] || 0;
+}
+
+function daysBetween(leftIso, rightIso) {
+ return Math.round((new Date(rightIso).getTime() - new Date(leftIso).getTime()) / 86400000);
+}
+
+function renderMarkdownReport(report) {
+ const lines = [
+ `# Access Invitation Policy Audit: ${report.project.title}`,
+ "",
+ `- Decision: ${report.summary.decision}`,
+ `- Risk score: ${report.summary.riskScore}`,
+ `- Visibility: ${report.project.visibility}`,
+ `- Invitations reviewed: ${report.summary.invitationsReviewed}`,
+ `- Memberships reviewed: ${report.summary.membershipsReviewed}`,
+ `- Object permissions reviewed: ${report.summary.objectPermissionsReviewed}`,
+ `- Audit digest: ${report.summary.auditDigest}`,
+ "",
+ "## Findings",
+ "",
+ ];
+
+ for (const finding of report.findings) {
+ lines.push(`- [${finding.severity}] ${finding.code}${finding.userId ? ` (${finding.userId})` : ""}`);
+ lines.push(` - ${finding.message}`);
+ lines.push(` - Recommendation: ${finding.recommendation}`);
+ }
+
+ lines.push("", "## Access Gates", "");
+ for (const gate of report.accessGates) {
+ lines.push(`- [${gate.passed ? "x" : "!"}] ${gate.gate}`);
+ }
+
+ return `${lines.join("\n")}\n`;
+}
+
+module.exports = {
+ auditAccessPolicy,
+ renderMarkdownReport,
+ roleRank,
+ daysBetween,
+};
diff --git a/user-project-management/access-invitation-policy-auditor/make-demo-video.js b/user-project-management/access-invitation-policy-auditor/make-demo-video.js
new file mode 100644
index 00000000..2f502802
--- /dev/null
+++ b/user-project-management/access-invitation-policy-auditor/make-demo-video.js
@@ -0,0 +1,101 @@
+"use strict";
+
+const fs = require("fs");
+const path = require("path");
+const { spawnSync } = require("child_process");
+const { auditAccessPolicy } = require("./index");
+const { projectAccessPacket } = require("./sample-data");
+
+const WIDTH = 1280;
+const HEIGHT = 720;
+const FPS = 12;
+const FRAMES = 48;
+
+function rgb(hex) {
+ const value = Number.parseInt(hex.replace("#", ""), 16);
+ return [(value >> 16) & 255, (value >> 8) & 255, value & 255];
+}
+
+function canvas(fill) {
+ const buffer = Buffer.alloc(WIDTH * HEIGHT * 3);
+ const [r, g, b] = rgb(fill);
+ for (let i = 0; i < buffer.length; i += 3) {
+ buffer[i] = r;
+ buffer[i + 1] = g;
+ buffer[i + 2] = b;
+ }
+ return buffer;
+}
+
+function rect(buffer, x, y, width, height, color) {
+ const [r, g, b] = rgb(color);
+ const left = Math.max(0, Math.floor(x));
+ const top = Math.max(0, Math.floor(y));
+ const right = Math.min(WIDTH, Math.floor(x + width));
+ const bottom = Math.min(HEIGHT, Math.floor(y + height));
+ for (let row = top; row < bottom; row += 1) {
+ for (let col = left; col < right; col += 1) {
+ const index = (row * WIDTH + col) * 3;
+ buffer[index] = r;
+ buffer[index + 1] = g;
+ buffer[index + 2] = b;
+ }
+ }
+}
+
+function writeFrame(file, buffer) {
+ fs.writeFileSync(file, Buffer.concat([Buffer.from(`P6\n${WIDTH} ${HEIGHT}\n255\n`), buffer]));
+}
+
+function main() {
+ const report = auditAccessPolicy(projectAccessPacket);
+ const reportsDir = path.join(__dirname, "reports");
+ const framesDir = path.join(reportsDir, "ppm-frames");
+ fs.rmSync(framesDir, { recursive: true, force: true });
+ fs.mkdirSync(framesDir, { recursive: true });
+
+ const bars = [
+ { y: 252, width: 860 * (report.summary.riskScore / 100), color: "#ff6b6b" },
+ { y: 352, width: 860 * (report.summary.criticalFindings / report.summary.findings), color: "#ffa94d" },
+ { y: 452, width: 860 * (report.summary.highFindings / report.summary.findings), color: "#7cc4ff" },
+ { y: 552, width: 860 * (report.summary.objectPermissionsReviewed / 4), color: "#ffcf5a" },
+ ];
+
+ for (let frame = 0; frame < FRAMES; frame += 1) {
+ const progress = Math.min(1, (frame + 1) / 30);
+ const buffer = canvas("#07111f");
+ rect(buffer, 54, 52, 1172, 616, "#101c2f");
+ rect(buffer, 54, 52, 1172, 4, "#29415f");
+ rect(buffer, 54, 664, 1172, 4, "#29415f");
+ rect(buffer, 54, 52, 4, 616, "#29415f");
+ rect(buffer, 1222, 52, 4, 616, "#29415f");
+ rect(buffer, 88, 104, 760, 42, "#ffffff");
+ rect(buffer, 88, 170, 560, 22, "#b9c8dd");
+ rect(buffer, 88, 214, 560, 22, "#ffcf5a");
+ for (const bar of bars) {
+ rect(buffer, 88, bar.y, 860, 36, "#273854");
+ rect(buffer, 88, bar.y, bar.width * progress, 36, bar.color);
+ }
+ rect(buffer, 1002, 250, 72, 72, "#ff6b6b");
+ rect(buffer, 1092, 250, 72, 72, "#ff6b6b");
+ rect(buffer, 1002, 372, 162, 48, "#ffa94d");
+ rect(buffer, 1002, 492, 162, 48, "#7cc4ff");
+ writeFrame(path.join(framesDir, `frame-${String(frame).padStart(3, "0")}.ppm`), buffer);
+ }
+
+ const output = path.join(reportsDir, "demo.mp4");
+ const result = spawnSync(
+ "ffmpeg",
+ ["-y", "-framerate", String(FPS), "-i", path.join(framesDir, "frame-%03d.ppm"), "-pix_fmt", "yuv420p", "-movflags", "+faststart", output],
+ { stdio: "inherit" }
+ );
+ if (result.status !== 0) {
+ throw new Error("ffmpeg failed to create demo video");
+ }
+ fs.rmSync(framesDir, { recursive: true, force: true });
+ console.log(`demo video=${output}`);
+}
+
+if (require.main === module) {
+ main();
+}
diff --git a/user-project-management/access-invitation-policy-auditor/reports/access-invitation-policy-audit.json b/user-project-management/access-invitation-policy-auditor/reports/access-invitation-policy-audit.json
new file mode 100644
index 00000000..66ffef8c
--- /dev/null
+++ b/user-project-management/access-invitation-policy-auditor/reports/access-invitation-policy-audit.json
@@ -0,0 +1,332 @@
+{
+ "project": {
+ "id": "project-clinical-dataset",
+ "title": "Clinical Biomarker Collaboration Workspace",
+ "visibility": "institutional-only"
+ },
+ "summary": {
+ "decision": "hold-project-access",
+ "riskScore": 100,
+ "auditDigest": "9164efd10da8a9b9",
+ "invitationsReviewed": 2,
+ "membershipsReviewed": 4,
+ "objectPermissionsReviewed": 3,
+ "findings": 16,
+ "criticalFindings": 6,
+ "highFindings": 4
+ },
+ "findings": [
+ {
+ "severity": "critical",
+ "recommendation": "Disable the invitation and rotate the access token.",
+ "code": "EXPIRED_INVITE_ACTIVE",
+ "inviteId": "invite-001",
+ "userId": "external-ravi",
+ "message": "Invitation invite-001 is active after expiry 2026-05-20T00:00:00Z.",
+ "evidence": {
+ "id": "invite-001",
+ "userId": "external-ravi",
+ "status": "active",
+ "requestedRole": "reviewer",
+ "role": "admin",
+ "ownerApprovalId": "",
+ "expiresAt": "2026-05-20T00:00:00Z"
+ }
+ },
+ {
+ "severity": "high",
+ "recommendation": "Require owner approval before increasing collaborator role scope.",
+ "code": "ROLE_ESCALATION_WITHOUT_APPROVAL",
+ "inviteId": "invite-001",
+ "userId": "external-ravi",
+ "message": "Invitation invite-001 grants admin after reviewer request without owner approval.",
+ "evidence": {
+ "id": "invite-001",
+ "userId": "external-ravi",
+ "status": "active",
+ "requestedRole": "reviewer",
+ "role": "admin",
+ "ownerApprovalId": "",
+ "expiresAt": "2026-05-20T00:00:00Z"
+ }
+ },
+ {
+ "severity": "high",
+ "recommendation": "Require ORCID or institutional verification before granting elevated roles.",
+ "code": "UNVERIFIED_IDENTITY_PRIVILEGE",
+ "inviteId": "invite-001",
+ "userId": "external-ravi",
+ "message": "external-ravi has elevated role admin without verified ORCID or SAML identity.",
+ "evidence": {
+ "invite": {
+ "id": "invite-001",
+ "userId": "external-ravi",
+ "status": "active",
+ "requestedRole": "reviewer",
+ "role": "admin",
+ "ownerApprovalId": "",
+ "expiresAt": "2026-05-20T00:00:00Z"
+ },
+ "identity": {
+ "userId": "external-ravi",
+ "orcidVerified": false,
+ "samlVerified": false,
+ "institutionDomain": ""
+ }
+ }
+ },
+ {
+ "severity": "medium",
+ "recommendation": "Write an immutable audit-log event for every invite, role, and object-permission change.",
+ "code": "AUDIT_LOG_GAP",
+ "inviteId": "invite-001",
+ "userId": "external-ravi",
+ "message": "Invitation invite-001 has no immutable audit-log event.",
+ "evidence": {
+ "id": "invite-001",
+ "userId": "external-ravi",
+ "status": "active",
+ "requestedRole": "reviewer",
+ "role": "admin",
+ "ownerApprovalId": "",
+ "expiresAt": "2026-05-20T00:00:00Z"
+ }
+ },
+ {
+ "severity": "critical",
+ "recommendation": "Restrict institutional-only access to verified SAML domains.",
+ "code": "INSTITUTION_SCOPE_MISMATCH",
+ "userId": "external-ravi",
+ "message": "external-ravi has institutional-only access without matching verified SAML domain.",
+ "evidence": {
+ "membership": {
+ "userId": "external-ravi",
+ "role": "admin",
+ "external": true,
+ "anonymous": false,
+ "lastActiveAt": "2026-04-01T18:00:00Z"
+ },
+ "identity": {
+ "userId": "external-ravi",
+ "orcidVerified": false,
+ "samlVerified": false,
+ "institutionDomain": ""
+ },
+ "institutionDomain": "northstar.edu"
+ }
+ },
+ {
+ "severity": "medium",
+ "recommendation": "Expire external collaborator access after inactivity or invite window close.",
+ "code": "STALE_EXTERNAL_COLLABORATOR",
+ "userId": "external-ravi",
+ "message": "External collaborator external-ravi has 57 inactive days with admin role.",
+ "evidence": {
+ "userId": "external-ravi",
+ "role": "admin",
+ "external": true,
+ "anonymous": false,
+ "lastActiveAt": "2026-04-01T18:00:00Z"
+ }
+ },
+ {
+ "severity": "medium",
+ "recommendation": "Write an immutable audit-log event for every invite, role, and object-permission change.",
+ "code": "AUDIT_LOG_GAP",
+ "userId": "external-ravi",
+ "message": "Membership external-ravi/admin has no role audit-log event.",
+ "evidence": {
+ "userId": "external-ravi",
+ "role": "admin",
+ "external": true,
+ "anonymous": false,
+ "lastActiveAt": "2026-04-01T18:00:00Z"
+ }
+ },
+ {
+ "severity": "critical",
+ "recommendation": "Restrict institutional-only access to verified SAML domains.",
+ "code": "INSTITUTION_SCOPE_MISMATCH",
+ "userId": "guest-anon",
+ "message": "guest-anon has institutional-only access without matching verified SAML domain.",
+ "evidence": {
+ "membership": {
+ "userId": "guest-anon",
+ "role": "viewer",
+ "external": true,
+ "anonymous": true,
+ "lastActiveAt": "2026-05-27T18:00:00Z"
+ },
+ "identity": {
+ "userId": "guest-anon",
+ "orcidVerified": false,
+ "samlVerified": false,
+ "institutionDomain": ""
+ },
+ "institutionDomain": "northstar.edu"
+ }
+ },
+ {
+ "severity": "critical",
+ "recommendation": "Block anonymous sessions from private or invitation-only workspaces.",
+ "code": "ANONYMOUS_PRIVATE_ACCESS",
+ "userId": "guest-anon",
+ "message": "Anonymous membership guest-anon can access institutional-only project.",
+ "evidence": {
+ "userId": "guest-anon",
+ "role": "viewer",
+ "external": true,
+ "anonymous": true,
+ "lastActiveAt": "2026-05-27T18:00:00Z"
+ }
+ },
+ {
+ "severity": "medium",
+ "recommendation": "Write an immutable audit-log event for every invite, role, and object-permission change.",
+ "code": "AUDIT_LOG_GAP",
+ "userId": "guest-anon",
+ "message": "Membership guest-anon/viewer has no role audit-log event.",
+ "evidence": {
+ "userId": "guest-anon",
+ "role": "viewer",
+ "external": true,
+ "anonymous": true,
+ "lastActiveAt": "2026-05-27T18:00:00Z"
+ }
+ },
+ {
+ "severity": "critical",
+ "recommendation": "Restrict institutional-only access to verified SAML domains.",
+ "code": "INSTITUTION_SCOPE_MISMATCH",
+ "userId": "partner-lee",
+ "message": "partner-lee has institutional-only access without matching verified SAML domain.",
+ "evidence": {
+ "membership": {
+ "userId": "partner-lee",
+ "role": "contributor",
+ "external": true,
+ "anonymous": false,
+ "lastActiveAt": "2026-05-26T18:00:00Z"
+ },
+ "identity": {
+ "userId": "partner-lee",
+ "orcidVerified": true,
+ "samlVerified": true,
+ "institutionDomain": "partnerlab.org"
+ },
+ "institutionDomain": "northstar.edu"
+ }
+ },
+ {
+ "severity": "critical",
+ "recommendation": "Apply object-level deny rules before project-level role grants.",
+ "code": "OBJECT_PERMISSION_BYPASS",
+ "userId": "external-ravi",
+ "objectId": "dataset-phi-raw",
+ "message": "external-ravi can download restricted dataset dataset-phi-raw.",
+ "evidence": {
+ "rule": {
+ "id": "rule-001",
+ "userId": "external-ravi",
+ "objectType": "dataset",
+ "objectId": "dataset-phi-raw",
+ "action": "download",
+ "effect": "allow"
+ },
+ "membership": {
+ "userId": "external-ravi",
+ "role": "admin",
+ "external": true,
+ "anonymous": false,
+ "lastActiveAt": "2026-04-01T18:00:00Z"
+ }
+ }
+ },
+ {
+ "severity": "high",
+ "recommendation": "Set explicit deny-download policy for restricted datasets.",
+ "code": "DATA_DOWNLOAD_NOT_RESTRICTED",
+ "userId": "external-ravi",
+ "objectId": "dataset-phi-raw",
+ "message": "dataset-phi-raw lacks explicit download deny for external-ravi.",
+ "evidence": {
+ "id": "rule-001",
+ "userId": "external-ravi",
+ "objectType": "dataset",
+ "objectId": "dataset-phi-raw",
+ "action": "download",
+ "effect": "allow"
+ }
+ },
+ {
+ "severity": "low",
+ "recommendation": "Write an immutable audit-log event for every invite, role, and object-permission change.",
+ "code": "AUDIT_LOG_GAP",
+ "userId": "external-ravi",
+ "objectId": "dataset-phi-raw",
+ "message": "Object permission rule-001 has no audit-log event.",
+ "evidence": {
+ "id": "rule-001",
+ "userId": "external-ravi",
+ "objectType": "dataset",
+ "objectId": "dataset-phi-raw",
+ "action": "download",
+ "effect": "allow"
+ }
+ },
+ {
+ "severity": "high",
+ "recommendation": "Set explicit deny-download policy for restricted datasets.",
+ "code": "DATA_DOWNLOAD_NOT_RESTRICTED",
+ "userId": "partner-lee",
+ "objectId": "dataset-consent-linkage",
+ "message": "dataset-consent-linkage lacks explicit download deny for partner-lee.",
+ "evidence": {
+ "id": "rule-002",
+ "userId": "partner-lee",
+ "objectType": "dataset",
+ "objectId": "dataset-consent-linkage",
+ "action": "view",
+ "effect": "allow"
+ }
+ },
+ {
+ "severity": "low",
+ "recommendation": "Write an immutable audit-log event for every invite, role, and object-permission change.",
+ "code": "AUDIT_LOG_GAP",
+ "userId": "partner-lee",
+ "objectId": "dataset-consent-linkage",
+ "message": "Object permission rule-002 has no audit-log event.",
+ "evidence": {
+ "id": "rule-002",
+ "userId": "partner-lee",
+ "objectType": "dataset",
+ "objectId": "dataset-consent-linkage",
+ "action": "view",
+ "effect": "allow"
+ }
+ }
+ ],
+ "accessGates": [
+ {
+ "gate": "Invitations expire and cannot silently escalate roles.",
+ "passed": false
+ },
+ {
+ "gate": "Institutional and private workspaces reject anonymous or mismatched identities.",
+ "passed": false
+ },
+ {
+ "gate": "Restricted datasets cannot be downloaded by object-permission bypass.",
+ "passed": false
+ },
+ {
+ "gate": "Elevated roles require verified identity.",
+ "passed": false
+ },
+ {
+ "gate": "Invite, membership, and object-permission changes are audit logged.",
+ "passed": false
+ }
+ ]
+}
\ No newline at end of file
diff --git a/user-project-management/access-invitation-policy-auditor/reports/access-invitation-policy-audit.md b/user-project-management/access-invitation-policy-auditor/reports/access-invitation-policy-audit.md
new file mode 100644
index 00000000..e88f5dfe
--- /dev/null
+++ b/user-project-management/access-invitation-policy-auditor/reports/access-invitation-policy-audit.md
@@ -0,0 +1,68 @@
+# Access Invitation Policy Audit: Clinical Biomarker Collaboration Workspace
+
+- Decision: hold-project-access
+- Risk score: 100
+- Visibility: institutional-only
+- Invitations reviewed: 2
+- Memberships reviewed: 4
+- Object permissions reviewed: 3
+- Audit digest: 9164efd10da8a9b9
+
+## Findings
+
+- [critical] EXPIRED_INVITE_ACTIVE (external-ravi)
+ - Invitation invite-001 is active after expiry 2026-05-20T00:00:00Z.
+ - Recommendation: Disable the invitation and rotate the access token.
+- [high] ROLE_ESCALATION_WITHOUT_APPROVAL (external-ravi)
+ - Invitation invite-001 grants admin after reviewer request without owner approval.
+ - Recommendation: Require owner approval before increasing collaborator role scope.
+- [high] UNVERIFIED_IDENTITY_PRIVILEGE (external-ravi)
+ - external-ravi has elevated role admin without verified ORCID or SAML identity.
+ - Recommendation: Require ORCID or institutional verification before granting elevated roles.
+- [medium] AUDIT_LOG_GAP (external-ravi)
+ - Invitation invite-001 has no immutable audit-log event.
+ - Recommendation: Write an immutable audit-log event for every invite, role, and object-permission change.
+- [critical] INSTITUTION_SCOPE_MISMATCH (external-ravi)
+ - external-ravi has institutional-only access without matching verified SAML domain.
+ - Recommendation: Restrict institutional-only access to verified SAML domains.
+- [medium] STALE_EXTERNAL_COLLABORATOR (external-ravi)
+ - External collaborator external-ravi has 57 inactive days with admin role.
+ - Recommendation: Expire external collaborator access after inactivity or invite window close.
+- [medium] AUDIT_LOG_GAP (external-ravi)
+ - Membership external-ravi/admin has no role audit-log event.
+ - Recommendation: Write an immutable audit-log event for every invite, role, and object-permission change.
+- [critical] INSTITUTION_SCOPE_MISMATCH (guest-anon)
+ - guest-anon has institutional-only access without matching verified SAML domain.
+ - Recommendation: Restrict institutional-only access to verified SAML domains.
+- [critical] ANONYMOUS_PRIVATE_ACCESS (guest-anon)
+ - Anonymous membership guest-anon can access institutional-only project.
+ - Recommendation: Block anonymous sessions from private or invitation-only workspaces.
+- [medium] AUDIT_LOG_GAP (guest-anon)
+ - Membership guest-anon/viewer has no role audit-log event.
+ - Recommendation: Write an immutable audit-log event for every invite, role, and object-permission change.
+- [critical] INSTITUTION_SCOPE_MISMATCH (partner-lee)
+ - partner-lee has institutional-only access without matching verified SAML domain.
+ - Recommendation: Restrict institutional-only access to verified SAML domains.
+- [critical] OBJECT_PERMISSION_BYPASS (external-ravi)
+ - external-ravi can download restricted dataset dataset-phi-raw.
+ - Recommendation: Apply object-level deny rules before project-level role grants.
+- [high] DATA_DOWNLOAD_NOT_RESTRICTED (external-ravi)
+ - dataset-phi-raw lacks explicit download deny for external-ravi.
+ - Recommendation: Set explicit deny-download policy for restricted datasets.
+- [low] AUDIT_LOG_GAP (external-ravi)
+ - Object permission rule-001 has no audit-log event.
+ - Recommendation: Write an immutable audit-log event for every invite, role, and object-permission change.
+- [high] DATA_DOWNLOAD_NOT_RESTRICTED (partner-lee)
+ - dataset-consent-linkage lacks explicit download deny for partner-lee.
+ - Recommendation: Set explicit deny-download policy for restricted datasets.
+- [low] AUDIT_LOG_GAP (partner-lee)
+ - Object permission rule-002 has no audit-log event.
+ - Recommendation: Write an immutable audit-log event for every invite, role, and object-permission change.
+
+## Access Gates
+
+- [!] Invitations expire and cannot silently escalate roles.
+- [!] Institutional and private workspaces reject anonymous or mismatched identities.
+- [!] Restricted datasets cannot be downloaded by object-permission bypass.
+- [!] Elevated roles require verified identity.
+- [!] Invite, membership, and object-permission changes are audit logged.
diff --git a/user-project-management/access-invitation-policy-auditor/reports/access-invitation-policy-summary.svg b/user-project-management/access-invitation-policy-auditor/reports/access-invitation-policy-summary.svg
new file mode 100644
index 00000000..97429d6c
--- /dev/null
+++ b/user-project-management/access-invitation-policy-auditor/reports/access-invitation-policy-summary.svg
@@ -0,0 +1,20 @@
+
diff --git a/user-project-management/access-invitation-policy-auditor/reports/demo.mp4 b/user-project-management/access-invitation-policy-auditor/reports/demo.mp4
new file mode 100644
index 00000000..84bec9b5
Binary files /dev/null and b/user-project-management/access-invitation-policy-auditor/reports/demo.mp4 differ
diff --git a/user-project-management/access-invitation-policy-auditor/sample-data.js b/user-project-management/access-invitation-policy-auditor/sample-data.js
new file mode 100644
index 00000000..304d5d45
--- /dev/null
+++ b/user-project-management/access-invitation-policy-auditor/sample-data.js
@@ -0,0 +1,125 @@
+"use strict";
+
+const projectAccessPacket = {
+ id: "project-clinical-dataset",
+ title: "Clinical Biomarker Collaboration Workspace",
+ visibility: "institutional-only",
+ institutionDomain: "northstar.edu",
+ auditTime: "2026-05-28T20:00:00Z",
+ policy: {
+ externalInactivityLimitDays: 30,
+ },
+ restrictedDatasets: ["dataset-phi-raw", "dataset-consent-linkage"],
+ identities: [
+ {
+ userId: "owner-maya",
+ orcidVerified: true,
+ samlVerified: true,
+ institutionDomain: "northstar.edu",
+ },
+ {
+ userId: "external-ravi",
+ orcidVerified: false,
+ samlVerified: false,
+ institutionDomain: "",
+ },
+ {
+ userId: "guest-anon",
+ orcidVerified: false,
+ samlVerified: false,
+ institutionDomain: "",
+ },
+ {
+ userId: "partner-lee",
+ orcidVerified: true,
+ samlVerified: true,
+ institutionDomain: "partnerlab.org",
+ },
+ ],
+ invitations: [
+ {
+ id: "invite-001",
+ userId: "external-ravi",
+ status: "active",
+ requestedRole: "reviewer",
+ role: "admin",
+ ownerApprovalId: "",
+ expiresAt: "2026-05-20T00:00:00Z",
+ },
+ {
+ id: "invite-002",
+ userId: "partner-lee",
+ status: "active",
+ requestedRole: "viewer",
+ role: "contributor",
+ ownerApprovalId: "approval-123",
+ expiresAt: "2026-06-10T00:00:00Z",
+ },
+ ],
+ memberships: [
+ {
+ userId: "owner-maya",
+ role: "owner",
+ external: false,
+ anonymous: false,
+ lastActiveAt: "2026-05-28T18:00:00Z",
+ },
+ {
+ userId: "external-ravi",
+ role: "admin",
+ external: true,
+ anonymous: false,
+ lastActiveAt: "2026-04-01T18:00:00Z",
+ },
+ {
+ userId: "guest-anon",
+ role: "viewer",
+ external: true,
+ anonymous: true,
+ lastActiveAt: "2026-05-27T18:00:00Z",
+ },
+ {
+ userId: "partner-lee",
+ role: "contributor",
+ external: true,
+ anonymous: false,
+ lastActiveAt: "2026-05-26T18:00:00Z",
+ },
+ ],
+ objectPermissions: [
+ {
+ id: "rule-001",
+ userId: "external-ravi",
+ objectType: "dataset",
+ objectId: "dataset-phi-raw",
+ action: "download",
+ effect: "allow",
+ },
+ {
+ id: "rule-002",
+ userId: "partner-lee",
+ objectType: "dataset",
+ objectId: "dataset-consent-linkage",
+ action: "view",
+ effect: "allow",
+ },
+ {
+ id: "rule-003",
+ userId: "owner-maya",
+ objectType: "code",
+ objectId: "analysis-notebook",
+ action: "edit",
+ effect: "allow",
+ },
+ ],
+ auditLog: [
+ { key: "invite:invite-002", at: "2026-05-18T10:00:00Z" },
+ { key: "member:owner-maya:owner", at: "2026-05-01T10:00:00Z" },
+ { key: "member:partner-lee:contributor", at: "2026-05-18T10:00:00Z" },
+ { key: "object:rule-003", at: "2026-05-19T10:00:00Z" },
+ ],
+};
+
+module.exports = {
+ projectAccessPacket,
+};
diff --git a/user-project-management/access-invitation-policy-auditor/test.js b/user-project-management/access-invitation-policy-auditor/test.js
new file mode 100644
index 00000000..4726b193
--- /dev/null
+++ b/user-project-management/access-invitation-policy-auditor/test.js
@@ -0,0 +1,33 @@
+"use strict";
+
+const assert = require("assert");
+const { auditAccessPolicy, renderMarkdownReport, roleRank, daysBetween } = require("./index");
+const { projectAccessPacket } = require("./sample-data");
+
+assert.strictEqual(roleRank("owner") > roleRank("viewer"), true);
+assert.strictEqual(daysBetween("2026-04-01T18:00:00Z", "2026-05-28T20:00:00Z"), 57);
+
+const report = auditAccessPolicy(projectAccessPacket);
+const codes = new Set(report.findings.map((finding) => finding.code));
+
+assert.strictEqual(report.summary.decision, "hold-project-access");
+assert.strictEqual(report.summary.riskScore, 100);
+assert.ok(codes.has("EXPIRED_INVITE_ACTIVE"), "missing expired invite detection");
+assert.ok(codes.has("ROLE_ESCALATION_WITHOUT_APPROVAL"), "missing role escalation detection");
+assert.ok(codes.has("OBJECT_PERMISSION_BYPASS"), "missing object permission bypass detection");
+assert.ok(codes.has("INSTITUTION_SCOPE_MISMATCH"), "missing institution domain validation");
+assert.ok(codes.has("UNVERIFIED_IDENTITY_PRIVILEGE"), "missing verified identity privilege detection");
+assert.ok(codes.has("ANONYMOUS_PRIVATE_ACCESS"), "missing anonymous private access detection");
+assert.ok(codes.has("AUDIT_LOG_GAP"), "missing audit log gap detection");
+assert.ok(codes.has("DATA_DOWNLOAD_NOT_RESTRICTED"), "missing restricted dataset deny detection");
+assert.ok(codes.has("STALE_EXTERNAL_COLLABORATOR"), "missing stale collaborator detection");
+
+const repeat = auditAccessPolicy(projectAccessPacket);
+assert.strictEqual(report.summary.auditDigest, repeat.summary.auditDigest, "digest should be stable");
+
+const markdown = renderMarkdownReport(report);
+assert.ok(markdown.includes("Access Invitation Policy Audit"));
+assert.ok(markdown.includes("hold-project-access"));
+assert.ok(markdown.includes(report.summary.auditDigest));
+
+console.log("access-invitation-policy-auditor tests passed");