From 838077c33977cc2ceb66213e94bee4cb7db50f5c Mon Sep 17 00:00:00 2001 From: davidrsdiaz Date: Thu, 28 May 2026 14:56:45 -0500 Subject: [PATCH] Add access invitation policy auditor --- .../README.md | 25 ++ .../access-invitation-policy-auditor/demo.js | 60 ++++ .../access-invitation-policy-auditor/index.js | 267 ++++++++++++++ .../make-demo-video.js | 101 ++++++ .../access-invitation-policy-audit.json | 332 ++++++++++++++++++ .../reports/access-invitation-policy-audit.md | 68 ++++ .../access-invitation-policy-summary.svg | 20 ++ .../reports/demo.mp4 | Bin 0 -> 8800 bytes .../sample-data.js | 125 +++++++ .../access-invitation-policy-auditor/test.js | 33 ++ 10 files changed, 1031 insertions(+) create mode 100644 user-project-management/access-invitation-policy-auditor/README.md create mode 100644 user-project-management/access-invitation-policy-auditor/demo.js create mode 100644 user-project-management/access-invitation-policy-auditor/index.js create mode 100644 user-project-management/access-invitation-policy-auditor/make-demo-video.js create mode 100644 user-project-management/access-invitation-policy-auditor/reports/access-invitation-policy-audit.json create mode 100644 user-project-management/access-invitation-policy-auditor/reports/access-invitation-policy-audit.md create mode 100644 user-project-management/access-invitation-policy-auditor/reports/access-invitation-policy-summary.svg create mode 100644 user-project-management/access-invitation-policy-auditor/reports/demo.mp4 create mode 100644 user-project-management/access-invitation-policy-auditor/sample-data.js create mode 100644 user-project-management/access-invitation-policy-auditor/test.js 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 ` + + + Access Invitation Policy Audit + ${escapeXml(report.project.title)} + Decision: ${escapeXml(report.summary.decision)} + + Audit risk + + + Critical findings + + + High findings + + + + ${report.summary.invitationsReviewed} invites • ${report.summary.membershipsReviewed} members • ${report.summary.objectPermissionsReviewed} object rules + Audit digest ${report.summary.auditDigest} + +`; +} + +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 @@ + + + + Access Invitation Policy Audit + Clinical Biomarker Collaboration Workspace + Decision: hold-project-access + + Audit risk + + + Critical findings + + + High findings + + + + 2 invites • 4 members • 3 object rules + Audit digest 9164efd10da8a9b9 + 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 0000000000000000000000000000000000000000..84bec9b526fcdf8c40293c050ccfb85d097d278c GIT binary patch literal 8800 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 e&tqjdL*O@$t(_QE@R 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");